Query Rewrites & SARGability - Unnesting, NOT IN vs NOT EXISTS, OR to UNION, Keyset Pagination & ORM N+1
Subquery unnesting to semi/anti joins vs the NOT IN NULL trap (real PG 17 plans and counts plus runnable sqlite3 repro); SARGability: functions and casts on columns vs half-open ranges, MySQL string-vs-number index rule; BitmapOr vs OR-to-UNION; OFFSET vs keyset pagination (150,020 vs 20 rows read in real PG); runnable ORM N+1 fingerprint detector; rewrite catalog table.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
What does SARGable mean?
Answer
The predicate can be used as an index search argument: the bare column compared with a constant using an indexable operator.
L2
Is date(created_at) = '2026-02-01' SARGable?
Answer
No. Use created_at >= '2026-02-01' AND created_at < '2026-02-02', or an expression index.
L3
Why can NOT IN return zero rows?
Answer
If the subquery returns a NULL, x NOT IN (...) is NULL or FALSE for every x, never TRUE.
L4
What does PostgreSQL do with EXISTS?
Answer
It unnests it into a semi join that can run as a hash, merge or nested loop join.
L5
How does PostgreSQL handle OR across two indexed columns?
Answer
It can combine both indexes with a BitmapOr; other engines may need a UNION rewrite.
L6
Why is deep OFFSET slow?
Answer
The database produces and discards every row before the offset, so cost grows with page depth.
L7
How do you detect N+1?
Answer
Count normalized query fingerprints per request; one fingerprint repeated per row is the signal.
Failure modes
NOT IN silently returns nothing
One NULL in the subquery column turned 10,000 matching users into 0, with no error.
Function on an indexed column
date_trunc('day', created_at) forced a Seq Scan over 200,000 rows that a range would have served from the index.
Deep OFFSET pages time out
OFFSET 150000 walked 150,020 index entries to return 20 rows.
Misconceptions
PostgreSQL runs a correlated EXISTS once per outer row.
It usually unnests it into a semi join.
Casting the literal and casting the column are the same.
id = '4242' coerces the literal and uses the index; id::text = '4242' casts the column and scans.
Hand-unnesting every subquery always helps.
PostgreSQL already unnests EXISTS and IN; check EXPLAIN before rewriting.
Interviewer traps
Saying NOT IN and NOT EXISTS are interchangeable.
They differ whenever the subquery column can be NULL, both in result and in plan.
Proposing keyset pagination without a tiebreaker.
Without a unique column such as id in the sort key, rows that share a sort value are skipped or repeated.
Design scenario
Same prompt for every reader.
Requirements
Every page loads in under 100 ms at any depth, filters use indexes, and one request sends a bounded number of queries.
Failure assumptions
- Some orders have a NULL customer_id.
- Crawlers walk to page 10,000.
- The ORM lazy-loads customers per row.
Constraints
- PostgreSQL 17.
- The ORM stays, but you can choose eager loading and raw queries.
Prompt
An admin dashboard lists orders with filters, deep pagination and each order's customer. It is fast in development and slow in production. Redesign the queries.
API
What cursor does the API return instead of a page number, and which filters does it accept?
Data
Which indexes (composite, expression) make the filters and the keyset order SARGable?
Architecture
How do you detect N+1 in tests and production, and which queries replace the per-row lookups?
Overview
The optimizer can only choose among plans that are semantically equivalent to what you wrote and that it knows how to derive. How you phrase a query therefore decides which plans are even on the table. Some rewrites the optimizer does for you (subquery unnesting: turning EXISTS/IN into semi joins, NOT EXISTS into anti joins, pulling up simple subqueries and flattening views). Some it cannot do because they would change the answer (NOT IN with a nullable column is not an anti join). Some it does not attempt (turning an OR across different columns into a UNION, rewriting a function on a column into a range). And some are about the application, not SQL: OFFSET pagination that reads and discards ever more rows, and ORM N+1 loops that send a thousand tiny queries instead of one set-based one. The umbrella term for "the predicate can use an index" is SARGable (Search ARGument-able): column op constant, with the bare column on one side. Wrap the column in a function or a cast and the index on that column becomes invisible.
What the optimizer rewrites for you, and what it can't
Decisions
- 1
Step 1: query as written
- nextStep 2: subquery shape?
- nextStep 4: predicate shape?
- ?
Step 2: subquery shape?
- EXISTS or IN (subquery)Step 3a: unnest to Semi Join - any join algorithm
- NOT EXISTSStep 3b: unnest to Anti Join
- NOT IN (subquery), column nullableStep 3c: stays a filter with hashed SubPlan - NULL semantics block anti join
- correlated scalar subquery in SELECTStep 3d: often runs once per outer row - consider JOIN or LATERAL
- 3
Step 3a: unnest to Semi Join - any join algorithm
- 4
Step 3b: unnest to Anti Join
- 5
Step 3c: stays a filter with hashed SubPlan - NULL semantics block anti join
- 6
Step 3d: often runs once per outer row - consider JOIN or LATERAL
- ?
Step 4: predicate shape?
- col op constantStep 5a: SARGable - index range scan possible
- f(col) op constant, col::type, col + 1 = ?Step 5b: not SARGable - needs expression index or rewrite
- 8
Step 5a: SARGable - index range scan possible
- 9
Step 5b: not SARGable - needs expression index or rewrite
Lesson map
Query Rewrites & SARGability - Unnesting, NOT IN vs NOT EXISTS, OR to UNION, Keyset Pagination & ORM N+1
Subquery unnesting to semi/anti joins vs the NOT IN NULL trap (real PG 17 plans and counts plus runnable sqlite3 repro); SARGability: functions and casts on columns vs half-open ranges, MySQL string-vs-number index rule; BitmapOr vs OR-to-UNION; OFFSET vs keyset pagination (150,020 vs 20 rows read in real PG); runnable ORM N+1 fingerprint detector; rewrite catalog table.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB q["Step 1: query as written"] u["Step 2: subquery shape?"] sj["Step 3a: unnest to Semi Join - any join algorithm"] aj["Step 3b: unnest to Anti Join"] sp["Step 3c: stays a filter with hashed SubPlan - NULL semantics block anti join"] cs["Step 3d: often runs once per outer row - consider JOIN or LATERAL"] p["Step 4: predicate shape?"] ix["Step 5a: SARGable - index range scan possible"] nx["Step 5b: not SARGable - needs expression index or rewrite"] q -->|continues| u u -->|EXISTS or IN (subquery)| sj u -->|NOT EXISTS| aj u -->|NOT IN (subquery), column nullable| sp u -->|correlated scalar subquery in SELECT| cs q -->|continues| p p -->|col op constant| ix p -->|f(col) op constant, col::type, col + 1 = ?| nx
Subquery unnesting (decorrelation). A correlated EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) naively means "run the subquery for every user". PostgreSQL pulls it up into a semi join ("return each left row at most once if any right row matches"), and the semi join can then be executed with any algorithm: hash, merge or nested loop. Similarly NOT EXISTS becomes an anti join. IN (subquery) is equivalent to a semi join and is unnested the same way. Scalar subqueries in the select list and some complex correlated forms are not always unnested; a LATERAL join or an explicit aggregate-then-join is the manual rewrite (the existing LATERAL page covers that comparison).
Why NOT IN is different. x NOT IN (a, b, NULL) means x <> a AND x <> b AND x <> NULL. The last term is NULL (unknown), so the whole expression is never TRUE; the WHERE clause drops every row. Because of that three-valued logic, NOT IN over a nullable column is not equivalent to an anti join, so the planner cannot unnest it; PostgreSQL evaluates it as a filter against a hashed subplan (or, if the subquery is too big to hash, a plain subplan per row, which is catastrophic).
Rewrite catalog: from slow shape to optimizer-friendly shape
| Slow shape | Why it hurts | Rewrite | Notes |
|---|---|---|---|
WHERE date(created_at) = '2026-02-01' | Function hides the indexed column | created_at >= '2026-02-01' AND created_at < '2026-02-02' | Or an expression index on date(created_at) if the expression is fixed |
WHERE lower(email) = $1 | Same | Expression index ON users (lower(email)), or a citext column | See the partial & expression indexes page |
WHERE id::text = $1 / ORM binds wrong type | Cast on the column side | Bind the parameter with the column's type | Common with UUIDs and bigints sent as text |
WHERE amount + 10 > 100 | Arithmetic on the column | WHERE amount > 90 | Move arithmetic to the constant side |
WHERE name LIKE '%son' | Leading wildcard: no B-tree range | Trigram index (pg_trgm GIN/GiST), reverse-string index, full-text search | LIKE 'son%' is SARGable (with a suitable collation/opclass) |
WHERE a = 1 OR b = 2 | Single-index access impossible | BitmapOr (PostgreSQL does this), or UNION of two indexed queries | MySQL uses index_merge in some cases |
x NOT IN (SELECT nullable_col ...) | Wrong answer with NULLs, no anti join | NOT EXISTS (...) | Or filter IS NOT NULL inside the subquery |
SELECT ..., (SELECT max(..) FROM t2 WHERE t2.k = t1.k) per row | Correlated subquery per outer row | JOIN (SELECT k, max(..) ... GROUP BY k) or LATERAL | Depends on selectivity; see the LATERAL page |
OFFSET 100000 LIMIT 20 | Reads and discards 100,000 rows | Keyset: WHERE (sort_key, id) > ($last_key, $last_id) ORDER BY sort_key, id LIMIT 20 | Needs an index on (sort_key, id); no "jump to page N" |
SELECT COUNT(*) for "has any?" | Counts everything | SELECT EXISTS (SELECT 1 ... ) | Stops at the first match |
SELECT DISTINCT to hide a join fan-out | Sort/hash of a blown-up result | Use EXISTS for the filtering side | Duplicates usually mean the join is wrong |
| ORM loop: 1 query + N lookups | N round trips, N plans | Eager load (JOIN or WHERE id = ANY($1)) | Detect with query fingerprints per request |
Keyset or OFFSET pagination for deep pages?
Prefer
Keyset (seek) pagination
Remember the last row's sort key and id, and ask for rows after it.
- The real plan read 20 index entries for page 7,501.
- Every page costs the same and is stable under inserts.
- It needs an index on (sort_key, id) and cannot jump to page N.
Alternative
OFFSET pagination
Skip n rows, then return the page.
- OFFSET 150000 LIMIT 20 read 150,020 index entries.
- Page 50,000 would read 1,000,000 rows in the model.
- Easy to build and jump around in, with rows shifting between pages.
What the optimizer can rewrite and what it cannot
Diagram 1 condensed.
- 1
EXISTS or IN
Unnested into a semi join; any join algorithm can run it. - 2
NOT EXISTS
Unnested into an anti join. - 3
NOT IN over a nullable column
Stays a hashed SubPlan filter; NULL semantics block the anti join. - 4
Bare column vs constant
SARGable: an index range scan is possible. - 5
Function or cast on the column
Not SARGable without an expression index or a rewrite.
Real PostgreSQL: unnesting, the NOT IN trap, SARGability and pagination
-- Page 6: how query shape decides what the optimizer can do.
\pset footer off
SET client_min_messages = warning;
DROP SCHEMA IF EXISTS p6 CASCADE;
CREATE SCHEMA p6;
SET search_path = p6;
SET max_parallel_workers_per_gather = 0;
CREATE TABLE users AS SELECT g AS id, 'u' || g || '@example.com' AS email FROM generate_series(1, 50000) g;
ALTER TABLE users ADD PRIMARY KEY (id);
CREATE TABLE orders AS
SELECT g AS id,
CASE WHEN g % 50000 = 0 THEN NULL ELSE 1 + (g * 13) % 40000 END AS user_id, -- one NULL user_id
timestamptz '2026-01-01' + (g || ' minutes')::interval AS created_at,
'SKU-' || (g % 900) AS sku
FROM generate_series(1, 200000) g;
ALTER TABLE orders ADD PRIMARY KEY (id);
CREATE INDEX orders_created_idx ON orders (created_at);
CREATE INDEX orders_user_idx ON orders (user_id);
CREATE INDEX orders_sku_idx ON orders (sku);
VACUUM ANALYZE users; VACUUM ANALYZE orders;
-- 1) EXISTS / IN are unnested into a Semi Join; NOT EXISTS into an Anti Join
EXPLAIN (COSTS OFF) SELECT count(*) FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
EXPLAIN (COSTS OFF) SELECT count(*) FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- 2) NOT IN cannot become an anti join (NULL semantics) and, worse, one NULL makes it return nothing
SELECT count(*) AS not_exists_count FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
SELECT count(*) AS not_in_count FROM users u WHERE u.id NOT IN (SELECT o.user_id FROM orders o);
EXPLAIN (COSTS OFF) SELECT count(*) FROM users u WHERE u.id NOT IN (SELECT o.user_id FROM orders o);
-- 3) SARGability: a function on the indexed column hides it from the index; a half-open range does not
EXPLAIN (COSTS OFF) SELECT count(*) FROM orders WHERE date_trunc('day', created_at) = timestamptz '2026-02-01';
EXPLAIN (COSTS OFF) SELECT count(*) FROM orders
WHERE created_at >= timestamptz '2026-02-01' AND created_at < timestamptz '2026-02-02';
-- 4) Casting the column (often what an ORM or a mismatched parameter type does) has the same effect
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id::text = '4242';
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id = '4242'; -- literal is coerced to int instead: index usable
-- 5) OR across different columns: PostgreSQL can BitmapOr two indexes (MySQL index_merge, or rewrite to UNION)
EXPLAIN (COSTS OFF) SELECT count(*) FROM orders WHERE user_id = 7 OR sku = 'SKU-5';
-- 6) Deep OFFSET pagination reads and throws away rows; keyset (seek) pagination starts at the right place
CREATE INDEX orders_created_id_idx ON orders (created_at, id); -- same index serves both queries
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, created_at FROM orders ORDER BY created_at, id OFFSET 150000 LIMIT 20;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, created_at FROM orders
WHERE (created_at, id) > (timestamptz '2026-04-15 04:00:00+00', 150000)
ORDER BY created_at, id LIMIT 20;Real output (PostgreSQL 17.11, local sandbox, psql; setup DDL echoes omitted):
SET max_parallel_workers_per_gather = 0;
EXPLAIN (COSTS OFF) SELECT count(*) FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
QUERY PLAN
----------------------------------------------
Aggregate
-> Hash Join
Hash Cond: (u.id = o.user_id)
-> Seq Scan on users u
-> Hash
-> HashAggregate
Group Key: o.user_id
-> Seq Scan on orders o
EXPLAIN (COSTS OFF) SELECT count(*) FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
QUERY PLAN
---------------------------------------
Aggregate
-> Hash Right Anti Join
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o
-> Hash
-> Seq Scan on users u
SELECT count(*) AS not_exists_count FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
not_exists_count
------------------
10000
SELECT count(*) AS not_in_count FROM users u WHERE u.id NOT IN (SELECT o.user_id FROM orders o);
not_in_count
--------------
0
EXPLAIN (COSTS OFF) SELECT count(*) FROM users u WHERE u.id NOT IN (SELECT o.user_id FROM orders o);
QUERY PLAN
------------------------------------------------------------
Aggregate
-> Seq Scan on users u
Filter: (NOT (ANY (id = (hashed SubPlan 1).col1)))
SubPlan 1
-> Seq Scan on orders o
EXPLAIN (COSTS OFF) SELECT count(*) FROM orders WHERE date_trunc('day', created_at) = timestamptz '2026-02-01';
QUERY PLAN
------------------------------------------------------------------------------------------------------------
Aggregate
-> Seq Scan on orders
Filter: (date_trunc('day'::text, created_at) = '2026-02-01 00:00:00-06'::timestamp with time zone)
EXPLAIN (COSTS OFF) SELECT count(*) FROM orders
WHERE created_at >= timestamptz '2026-02-01' AND created_at < timestamptz '2026-02-02';
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregate
-> Index Only Scan using orders_created_idx on orders
Index Cond: ((created_at >= '2026-02-01 00:00:00-06'::timestamp with time zone) AND (created_at < '2026-02-02 00:00:00-06'::timestamp with time zone))
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id::text = '4242';
QUERY PLAN
---------------------------------------
Seq Scan on users
Filter: ((id)::text = '4242'::text)
EXPLAIN (COSTS OFF) SELECT * FROM users WHERE id = '4242';
QUERY PLAN
--------------------------------------
Index Scan using users_pkey on users
Index Cond: (id = 4242)
EXPLAIN (COSTS OFF) SELECT count(*) FROM orders WHERE user_id = 7 OR sku = 'SKU-5';
QUERY PLAN
----------------------------------------------------------------
Aggregate
-> Bitmap Heap Scan on orders
Recheck Cond: ((user_id = 7) OR (sku = 'SKU-5'::text))
-> BitmapOr
-> Bitmap Index Scan on orders_user_idx
Index Cond: (user_id = 7)
-> Bitmap Index Scan on orders_sku_idx
Index Cond: (sku = 'SKU-5'::text)
CREATE INDEX orders_created_id_idx ON orders (created_at, id);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, created_at FROM orders ORDER BY created_at, id OFFSET 150000 LIMIT 20;
QUERY PLAN
------------------------------------------------------------------------------------------
Limit (actual rows=20 loops=1)
-> Index Only Scan using orders_created_id_idx on orders (actual rows=150020 loops=1)
Heap Fetches: 0
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, created_at FROM orders
WHERE (created_at, id) > (timestamptz '2026-04-15 04:00:00+00', 150000)
ORDER BY created_at, id LIMIT 20;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------
Limit (actual rows=20 loops=1)
-> Index Only Scan using orders_created_id_idx on orders (actual rows=20 loops=1)
Index Cond: (ROW(created_at, id) > ROW('2026-04-14 23:00:00-05'::timestamp with time zone, 150000))
Heap Fetches: 0Reading it:
- EXISTS became a join: PostgreSQL implemented the semi join as a Hash Join over a HashAggregate of distinct
user_ids (unique-ifying the inner side, then a plain inner join), one of several semi-join strategies the planner can cost. NOT EXISTS became a Hash Right Anti Join (PG 16+ can build the hash on either side of an anti join). - The NULL trap, in numbers:
NOT EXISTSfinds 10,000 users without orders;NOT INreturns 0, because one order row hasuser_id = NULL. The plan confirms it was not unnested:Filter: (NOT (ANY (id = (hashed SubPlan 1).col1))). - Function on the column:
date_trunc('day', created_at) = ...forced a Seq Scan over 200,000 rows; the equivalent half-open rangecreated_at >= '2026-02-01' AND created_at < '2026-02-02'used an Index Only Scan. (Literals were interpreted in the sandbox's America/Chicago time zone, hence the-06offsets.) - Cast on the column:
id::text = '4242'is a Seq Scan;id = '4242'is coerced on the literal side toid = 4242and uses the primary key. - OR across columns: PostgreSQL can combine two indexes with BitmapOr. Engines without that ability (or when the OR mixes indexed and unindexed columns) benefit from a manual rewrite to
UNION(orUNION ALLwith care for duplicates). - OFFSET vs keyset: with the same
(created_at, id)index,OFFSET 150000 LIMIT 20walked 150,020 index entries to return 20; the keyset versionWHERE (created_at, id) > (last_seen_created_at, last_seen_id)read exactly 20.
The same lessons in SQLite (runnable anywhere)
PostgreSQL is not always at hand in an interview sandbox; Python's built-in sqlite3 is. The snippet below reproduces the NULL trap, SARGability and pagination costs with real EXPLAIN QUERY PLAN output from SQLite 3.46.
"""Query rewrites you can verify in any Python install (stdlib sqlite3, real EXPLAIN QUERY PLAN output).
1) NOT IN vs NOT EXISTS with a NULL in the subquery.
2) SARGability: function on an indexed column vs an equivalent range.
3) Type mismatches: literal coerced (fine) vs column cast (index lost).
4) OFFSET vs keyset pagination: rows the engine has to step over.
"""
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users(id INTEGER PRIMARY KEY, email TEXT);
CREATE TABLE orders(id INTEGER PRIMARY KEY, user_id INTEGER, created_at TEXT, phone TEXT);
CREATE INDEX orders_created ON orders(created_at);
CREATE INDEX orders_phone ON orders(phone);
""")
db.executemany("INSERT INTO users VALUES (?, ?)", [(i, f"u{i}@example.com") for i in range(1, 1001)])
db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?)",
[(i, None if i == 500 else 1 + i % 800, # exactly one NULL user_id
f"2026-{1 + i % 12:02d}-{1 + i % 28:02d} 10:00:00", f"555{i:04d}") for i in range(1, 5001)])
db.execute("ANALYZE")
def plan(sql):
return " | ".join(row[3] for row in db.execute("EXPLAIN QUERY PLAN " + sql))
print("sqlite", sqlite3.sqlite_version)
# 1) NULL trap: x NOT IN (..., NULL) is never TRUE (it is NULL or FALSE), so the filter drops every row
q_not_in = "SELECT count(*) FROM users WHERE id NOT IN (SELECT user_id FROM orders)"
q_not_exists = "SELECT count(*) FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)"
print("NOT IN ->", db.execute(q_not_in).fetchone()[0])
print("NOT EXISTS ->", db.execute(q_not_exists).fetchone()[0])
print("NOT IN + IS NOT NULL ->", db.execute(q_not_in.replace("FROM orders)", "FROM orders WHERE user_id IS NOT NULL)")).fetchone()[0])
# 2) SARGable vs not
print("function on column:", plan("SELECT count(*) FROM orders WHERE substr(created_at, 1, 7) = '2026-03'"))
print("half-open range :", plan("SELECT count(*) FROM orders WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'"))
# 3) Type mismatches. SQLite applies the column's TEXT affinity to the literal, so the index still works;
# casting the *column* (what an ORM or a careless refactor does) hides it from the index.
# (MySQL is stricter: string_column = 123 converts the column side and cannot use the index.)
print("phone = 5550042 :", plan("SELECT * FROM orders WHERE phone = 5550042"))
print("CAST(phone AS INT)=5550042:", plan("SELECT * FROM orders WHERE CAST(phone AS INTEGER) = 5550042"))
# 4) OFFSET vs keyset: count VM steps with a progress handler as a proxy for work done
def steps(sql):
n = [0]
def tick(): n[0] += 1; return 0
db.set_progress_handler(tick, 10) # callback every 10 VM instructions
db.execute(sql).fetchall(); db.set_progress_handler(None, 0)
return n[0] * 10
off = steps("SELECT id FROM orders ORDER BY id LIMIT 20 OFFSET 4000")
key = steps("SELECT id FROM orders WHERE id > 4000 ORDER BY id LIMIT 20")
print(f"OFFSET 4000 page: ~{off:,} VM steps | keyset page (id > 4000): ~{key:,} VM steps")Output:
sqlite 3.46.1
NOT IN -> 0
NOT EXISTS -> 200
NOT IN + IS NOT NULL -> 200
function on column: SCAN orders USING COVERING INDEX orders_created
half-open range : SEARCH orders USING COVERING INDEX orders_created (created_at>? AND created_at<?)
phone = 5550042 : SEARCH orders USING INDEX orders_phone (phone=?)
CAST(phone AS INT)=5550042: SCAN orders
OFFSET 4000 page: ~8,110 VM steps | keyset page (id > 4000): ~80 VM stepsNote the engine difference on types: SQLite applies the column's TEXT affinity to the numeric literal (phone = 5550042 still uses the index), while an explicit CAST(phone AS INTEGER) on the column produces SCAN orders. MySQL is stricter in the other direction: its manual states that for comparisons of a string column with a number, MySQL cannot use an index on the column, because many strings ('1', ' 1', '1a') convert to the same number. PostgreSQL refuses text = integer outright (no implicit cast), which is annoying and safer.
OFFSET vs keyset pagination
OFFSET pagination is easy to build and easy to jump around in, but every page re-reads all preceding rows: page k costs O(k x page size), and concurrent inserts shift rows between pages (users see duplicates or miss rows). Keyset (seek) pagination remembers the last row's sort key and asks for "rows after this one"; every page costs O(page size) and is stable under inserts, but it cannot jump to an arbitrary page number and needs a unique tiebreaker (id) in the sort key. The existing API pagination page covers the API contract side (cursor tokens, filters, sorting); here the point is the plan: a deep OFFSET is a scan disguised as a page.
ORM N+1 detection
Sequence
- 1
"App (ORM)" → "PostgreSQL"
"Step 1: SELECT posts ... LIMIT 25"
- 2
"PostgreSQL" → "App (ORM)"
"Step 2: 25 rows"
- 3
"App (ORM)" → "PostgreSQL"
"SELECT users WHERE id = ?"
- 4
"PostgreSQL" → "App (ORM)"
"1 row"
- 5
"App (ORM)"
"Step 4: 26 round trips, each fast, total slow"
- 6
"App (ORM)" → "PostgreSQL"
"Step 5 (fix): SELECT users WHERE id = ANY($1), one query"
The runnable detector fingerprints queries the way pg_stat_statements normalizes them (literals replaced by placeholders) and flags any fingerprint repeated many times within one request; it also tabulates OFFSET vs keyset reads per page.
// Two app-side query-shape problems the optimizer cannot fix for you:
// 1) ORM N+1: one query for a list, then one query per row. Detect it by fingerprinting SQL
// (strip literals) and counting repeats per request, the way pg_stat_statements groups queries.
// 2) OFFSET pagination: page k costs O(k * pageSize) rows read; keyset (seek) costs O(pageSize).
// The request log and table sizes are example values.
function fingerprint(sql: string): string {
return sql
.replace(/'(?:[^']|'')*'/g, "?") // string literals
.replace(/\b\d+(\.\d+)?\b/g, "?") // numeric literals
.replace(/\bin\s*\(\s*\?(\s*,\s*\?)*\s*\)/gi, "IN (?)") // collapse IN lists
.replace(/\s+/g, " ")
.trim();
}
function detectNPlusOne(log: string[], threshold = 5): Array<[string, number]> {
const counts = new Map<string, number>();
for (const q of log) counts.set(fingerprint(q), (counts.get(fingerprint(q)) ?? 0) + 1);
return [...counts.entries()].filter(([, n]) => n >= threshold).sort((a, b) => b[1] - a[1]);
}
// One HTTP request rendering 25 posts with their authors, ORM lazy-loading style:
const requestLog: string[] = ["SELECT id, author_id, title FROM posts ORDER BY created_at DESC LIMIT 25"];
for (let i = 0; i < 25; i++) requestLog.push(`SELECT id, name FROM users WHERE id = ${100 + i}`);
console.log(`queries in request: ${requestLog.length}`);
for (const [fp, n] of detectNPlusOne(requestLog)) console.log(`N+1 suspect x${n}: ${fp}`);
// The fix is one set-based query (JOIN, or WHERE id IN (...) / = ANY($1) via eager loading):
const batched = `SELECT id, name FROM users WHERE id IN (${Array.from({ length: 25 }, (_, i) => 100 + i).join(", ")})`;
console.log(`after eager loading: 2 queries, second fingerprint = ${fingerprint(batched)}`);
// OFFSET vs keyset: rows the database must read (index entries walked) to serve page k
const pageSize = 20;
for (const page of [1, 100, 5_000, 50_000]) {
const offsetRows = (page - 1) * pageSize + pageSize; // walks and discards everything before the page
const keysetRows = pageSize; // seeks to (last_created_at, last_id) and reads one page
console.log(`page ${String(page).padStart(6)}: OFFSET reads ${offsetRows.toLocaleString("en-US").padStart(9)} rows, keyset reads ${keysetRows}`);
}Output:
queries in request: 26
N+1 suspect x25: SELECT id, name FROM users WHERE id = ?
after eager loading: 2 queries, second fingerprint = SELECT id, name FROM users WHERE id IN (?)
page 1: OFFSET reads 20 rows, keyset reads 20
page 100: OFFSET reads 2,000 rows, keyset reads 20
page 5000: OFFSET reads 100,000 rows, keyset reads 20
page 50000: OFFSET reads 1,000,000 rows, keyset reads 20Expectedqueries in request: 26 N+1 suspect x25: SELECT id, name FROM users WHERE id = ? after eager loading: 2 queries, second fingerprint = SELECT id, name FROM users WHERE id IN (?) page 1: OFFSET reads 20 rows, keyset reads 20 page 100: OFFSET reads 2,000 rows, keyset reads 20 page 5000: OFFSET reads 100,000 rows, keyset reads 20 page 50000: OFFSET reads 1,000,000 rows, keyset reads 20
Press Run. Snippets must be self-contained — no network, files, or native modules.
In production the same signal shows up as one pg_stat_statements entry with a huge calls count and tiny mean_exec_time, an APM trace with dozens of identical DB spans under one request, or ORM-specific tooling (Rails Bullet, Django's assertNumQueries and nplusone, Hibernate statistics, Prisma query logs). Fixes: eager loading (includes, select_related / prefetch_related, JOIN FETCH, Prisma include), a DataLoader-style batcher for GraphQL resolvers, or a single SQL query with a join.
What happens if you choose otherwise
- Keep
NOT IN"because it reads nicer": the day a NULL lands in the subquery column, the query silently returns nothing (no error, no warning), and reports or cleanup jobs act on an empty set. - Wrap indexed columns in functions for convenience: you get a correct answer with a sequential scan that grows with the table; it is fast in development and slow in production.
- Paginate with OFFSET on large tables: early pages are fast, late pages time out, and crawlers that walk to page 10,000 become a self-inflicted load test.
- Ignore N+1 because each query is 0.3 ms: at 200 lookups per request, round trips dominate latency and connection-pool time, and the database spends its CPU on parse/plan/execute overhead.
- Over-rewrite: hand-unnesting every subquery into joins can make queries harder to read without any plan change; check EXPLAIN first, since PostgreSQL already unnests
EXISTS/IN.
Pitfalls
LIKE 'abc%'uses a B-tree only with the C collation or atext_pattern_opsindex in non-C collations.UNION(without ALL) deduplicates with a sort or hash; useUNION ALLwhen the branches are disjoint.- Keyset pagination without a unique tiebreaker skips or repeats rows that share a sort key.
IS NULLpredicates are SARGable in PostgreSQL B-trees (NULLs are indexed), unlike some older engines.- Implicit casts in joins (
varchar=intin MySQL,uuidvstextvia ORM) silently disable index nested loops. - CTEs: since PostgreSQL 12, non-recursive CTEs referenced once are inlined; add
MATERIALIZEDonly when you want an optimization fence.
How the code was checked
- The SQLite script ran under Python 3.13 with the standard-library sqlite3 module (SQLite 3.46.1); its EXPLAIN QUERY PLAN lines are real output.
- The N+1 detector ran under
tsc --strictand Node 22, and its output block is the real captured output. - The SQL script ran through psql against a local PostgreSQL 17.11 sandbox (time zone America/Chicago, hence the -06 and -05 offsets). Only the setup DDL echoes were omitted.
Interview Q&A
What does SARGable mean? Give examples.
Answer
A predicate the engine can use as an index search argument: the bare indexed column compared with a constant or parameter using an indexable operator. created_at >= $1, id = 42, name LIKE 'abc%' (with suitable opclass) are SARGable; date(created_at) = $1, id::text = '42', amount + 10 > 100, name LIKE '%abc' are not without an expression index or rewrite.
Why can NOT IN return zero rows, and how do you fix it?
Answer
If the subquery returns any NULL, x NOT IN (...) evaluates to NULL or FALSE for every x, never TRUE, so the WHERE clause filters everything out. Use NOT EXISTS, which has two-valued semantics and is also unnested into an anti join, or filter NULLs out inside the subquery.
Does PostgreSQL execute a correlated EXISTS once per outer row?
Answer
Not usually. It unnests EXISTS and IN subqueries into semi joins and NOT EXISTS into anti joins, which can run as hash, merge or nested-loop joins. Scalar subqueries in the select list may still run per row.
Why is OFFSET pagination slow for deep pages and what is the alternative?
Answer
The database must produce and discard all rows before the offset, so cost grows linearly with page depth. Keyset pagination filters on the last seen sort key plus a unique tiebreaker (WHERE (created_at, id) > ($1, $2) ORDER BY created_at, id LIMIT 20) and uses an index to start at the right place, costing the same for every page.
How do you detect and fix N+1 queries?
Answer
Detect by counting normalized query fingerprints per request (APM traces, ORM logging, pg_stat_statements with high calls and low mean time). Fix with eager loading, batching ids into one = ANY($1) query, or a join.
When would you rewrite OR into UNION?
Answer
When the OR spans columns with separate indexes and the engine cannot combine them (PostgreSQL can, with BitmapOr), or when each branch benefits from a different plan. Use UNION ALL if branches are disjoint to avoid a dedup step.
How do MySQL, SQLite and PostgreSQL differ on string-vs-number comparisons?
Answer
SQLite applies the column's TEXT affinity to the numeric literal, so the index still works. MySQL cannot use an index on a string column compared with a number. PostgreSQL refuses text = integer outright.
When is an expression index better than rewriting the predicate?
Answer
When the expression is fixed and used often, such as lower(email), and there is no equivalent range form. The index must match the expression exactly.
Check yourself
Search your codebase for NOT IN (SELECT and for functions applied to indexed columns in WHERE clauses. For each one, check whether the column can be NULL or whether a range rewrite or expression index applies, and confirm with EXPLAIN.
Elsewhere in the library
These pages stay as they are. This lesson only points at them: Partial & Expression Indexes, Composite & Covering Indexes, LATERAL Joins vs Correlated Subqueries — When to Choose What, Pagination, Filtering, Sorting & Partial Responses.
Go Deeper
- Use The Index, Luke: Obfuscated conditions (functions, casts, math on columns)
- Use The Index, Luke: We need tool support for keyset pagination (no-offset)
- Use The Index, Luke: Nested loops and the ORM N+1 problem
- PostgreSQL wiki: Don't Do This (including NOT IN)
- PostgreSQL docs: Subquery Expressions (EXISTS, IN, NOT IN semantics)
- PostgreSQL docs: Index-Only Scans and Covering Indexes
- MySQL 8.4 Reference Manual: Type Conversion in Expression Evaluation
- SQLite: The SQLite Query Optimizer Overview
- SQLite: EXPLAIN QUERY PLAN