Skip to content

sargable joins — materialize the join key, don't parse it per row

nate
Jul 9, 20263 min read1 read

sargable joins — materialize the join key, don't parse it per row

ound chasing a leaflet /wrapped pathology: a publisher with 621 publications took >60s on the subscriber-count query while every individual query looked sub-millisecond in isolation.

the trap: joining on a value PARSED out of a column

subscriptions only stored the publication's full at-uri (at://<did>/<collection>/<rkey>). The publication join matched on the (did, rkey) decoded from it with substr/instr:

JOIN subscriptions s ON
  p.did  = substr(s.publication_uri, 6, instr(...) - 1)
  AND p.rkey = substr(substr(...), ...)

This is non-sargable: an index can only be used when a column appears bare on one side of the comparison. The moment you wrap it in a function the optimizer can't seek — it must compute the expression for every row, so the join degrades to a full table scan. EXPLAIN QUERY PLAN says it plainly:

|--SCAN s ← full scan, the fire |--SEARCH p USING INDEX ...

An expression index (CREATE INDEX ON subscriptions(substr(...))) does NOT help here — SQLite only matches one against a query whose expression is textually identical, and these nested substr/instr chains never line up. Don't bother.

the fix: compute the key once at write time, store it indexed

Add publication_did / publication_rkey columns, populate them wherever rows are written (live ingest, snapshot build, a one-shot backfill for existing rows), index (publication_did, publication_rkey), and join on the bare columns:

JOIN subscriptions s ON p.did = s.publication_did AND p.rkey = s.publication_rkey
|--SEARCH s USING INDEX idx_subscriptions_pub_did_rkey (publication_did=?) |--SEARCH p USING INDEX idx_publications_did_rkey (did=? AND rkey=?)

SCAN sSEARCH s. 45s → 0.39s at 16-way concurrency (measured). The convoy is gone because the work isn't there anymore, not because we threw cores at it.

General rule: a database is brilliant at seeking an indexed column and useless when handed a string to parse per row. Any time a join/WHERE predicate wraps a column in a function, that's the read path paying, every row, every query — move the computation to write time and index the result.

also: index the OTHER side

Same incident, sibling bug — publications had no (did, rkey) index at all, so even the trivial side scanned. 16 concurrent CLI readers = 45.5s; adding idx_publications_did_rkey alone took it to 0.39s. Under concurrency a full scan doesn't just cost its own rows — every reader contends, so latency goes superlinear (8-way 5.9s → 24-way 13.5s). Superlinear scaling on a read-only workload is the tell for a missing index, not a slow machine.

diagnosing "slow endpoint, fast queries"

The summed per-query span durations were ~280ms while the endpoint took 13s. That gap is NOT in the SQL the spans measured — it's in something the per-statement timing doesn't capture: here, the planner re-running a scan the EXPLAIN would have shown immediately. Read the query plan before theorizing about pools, allocators, span processors, or machine size. EXPLAIN QUERY PLAN is the first move, not the last.

Did you enjoy this article?

Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.

Across the AtmosphereDiscussions