Module 3 · Postgres & Data · Drills
Reading a plan and choosing an index are skills you build by doing, not by reading. Diagnose each one yourself — out loud, on paper — before you reveal the solution. This is exactly how the interview round feels.
EXPLAIN ANALYZE on the before/after and watch the timing move. Tick each box as you go; progress is saved in this browser.
Drill 1 EXPLAIN
This plan came from SELECT * FROM chunks WHERE document_id = 7; on a 2-million-row table. Why is it slow, and what's the fix?
Seq Scan on chunks (cost=0.00..48210 rows=42) (actual time=0.1..312.4 rows=40 loops=1) Filter: (document_id = 7) Rows Removed by Filter: 1999960
It's a Seq Scan reading all 2M rows to keep 40 — see Rows Removed by Filter: 1999960. The filter is highly selective (40 of 2M) but there's no index on document_id, so Postgres reads the whole table. The 312 ms is almost entirely wasted scanning.
CREATE INDEX idx_chunks_document_id ON chunks (document_id);
After this it flips to an Index Scan and drops to a fraction of a millisecond.
Drill 2 estimate gap
A node reads rows=10 (estimate) but actual rows=480000, and the planner chose a nested-loop join that's now crawling. What single maintenance command should you try first, and why?
The huge gap between estimate (10) and actual (480,000) means the planner's statistics are stale — it picked a nested loop because it expected almost no rows. Refresh the stats:
ANALYZE the_table;
With accurate row estimates the planner will likely switch to a hash join. Stale stats are a top cause of suddenly-bad plans after a big data change.
Drill 3 scan types
Match the scan to the situation: (a) filter matches 5 rows of 1M, (b) filter matches 900K of 1M, (c) filter matches ~20K of 1M. Which scan does Postgres pick for each?
The lesson: selectivity drives the choice. An index doesn't help a non-selective filter, and the planner is right to ignore it.
Drill 4 pick the index
This query is slow. Pick the single best index for it:
SELECT id, title, created_at FROM documents WHERE owner_id = 42 ORDER BY created_at DESC LIMIT 20;
CREATE INDEX idx_docs_owner_created ON documents (owner_id, created_at DESC);
Equality column (owner_id) first, sort column (created_at DESC) second. The index seeks straight to owner 42's rows already in date order, so Postgres returns the top 20 with no sort step and no extra rows read. Bonus: add INCLUDE (title) for an Index Only Scan that never touches the heap.
Drill 5 special index
Which index type for each? (a) WHERE metadata @> '{"source":"pdf"}' on a JSONB column; (b) finding the 5 nearest embeddings with ORDER BY embedding <=> $1 LIMIT 5; (c) WHERE lower(email) = $1.
-- (a) JSONB containment → GIN CREATE INDEX ON documents USING GIN (metadata); -- (b) vector nearest-neighbour → HNSW CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops); -- (c) function on the column → index the EXPRESSION CREATE INDEX ON users (lower(email));
(c) is the trap: a plain index on email can't serve lower(email) — you must index the same expression you filter on.
Drill 6 column order
You have CREATE INDEX ON chunks (chunk_index, document_id); but your only hot query is WHERE document_id = $1 ORDER BY chunk_index. Why is this index nearly useless, and what's correct?
By the leftmost-prefix rule, an index on (chunk_index, document_id) is sorted by chunk_index first — so it can't efficiently filter by document_id alone (that's the second column, only ordered within each chunk_index). Swap the order:
CREATE INDEX idx_chunks_doc_idx ON chunks (document_id, chunk_index);
Now it seeks to the document, with chunks already ordered by chunk_index — filter and sort both satisfied. Equality column first, always.
Drill 7 N+1
This SQLAlchemy 2.0 code fires one query per document. Rewrite it to two queries total, with no row fan-out.
from sqlalchemy import select docs = session.scalars(select(Document)).all() for doc in docs: summary[doc.id] = len(doc.chunks) # N extra queries!
from sqlalchemy import select from sqlalchemy.orm import selectinload stmt = select(Document).options(selectinload(Document.chunks)) docs = session.scalars(stmt).all() # 2 queries total for doc in docs: summary[doc.id] = len(doc.chunks) # no query — already loaded
selectinload is right for a one-to-many (a document's many chunks): the second query is … WHERE document_id IN (…), with no parent-row duplication. Use joinedload instead when loading a single related object (a chunk's one document).
Drill 8 keyset pagination
Convert this slow deep-page query to keyset (cursor) pagination, assuming the client sends the created_at and id of the last row it saw.
SELECT id, title, created_at FROM documents ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 10000;
SELECT id, title, created_at FROM documents WHERE (created_at, id) < (:last_created_at, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20;
With an index on (created_at DESC, id DESC), the database seeks straight past the last-seen row — constant time per page, no matter the depth. The id tie-breaker keeps rows with equal timestamps ordered deterministically. Trade-off: next/previous only, no jump-to-page-500.
Build · make retrieval fast
The query finds the 5 nearest chunks from PDF-sourced documents. Capture the "before" plan, add the right indexes, and capture the "after" plan. Write the SQL for all of it.
SELECT c.id, c.content, c.embedding <=> :q AS distance FROM chunks c JOIN documents d ON d.id = c.document_id WHERE d.metadata @> '{"source":"pdf"}' ORDER BY distance LIMIT 5;
Step 1 — capture the before plan:
EXPLAIN (ANALYZE, BUFFERS) /* the query above */; -- you'll see: Seq Scan on chunks (computes distance for every row), -- Seq Scan on documents (metadata filter), then a Sort. Hundreds of ms.
Step 2 — add the right indexes and refresh stats:
-- HNSW for approximate nearest-neighbour (match the cosine operator) CREATE INDEX idx_chunks_embedding ON chunks USING hnsw (embedding vector_cosine_ops); -- GIN for the JSONB containment filter CREATE INDEX idx_docs_metadata ON documents USING GIN (metadata); ANALYZE chunks; ANALYZE documents;
Step 3 — capture the after plan:
EXPLAIN (ANALYZE, BUFFERS) /* the same query */; -- now: Index Scan using idx_chunks_embedding (HNSW, no full sort), -- Bitmap Index Scan on idx_docs_metadata. Single-digit ms. -- tune recall vs speed: SET hnsw.ef_search = 100;
The before/after pair — a Seq Scan + Sort at hundreds of ms becoming an HNSW Index Scan at single-digit ms — is the whole story. That comparison is what you show to prove the optimization worked.
Tap a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.
Rows Removed by Filter.selectinload for one-to-many (2 queries, no fan-out); joinedload for many-to-one / one-to-one (single JOIN).USING hnsw (embedding vector_cosine_ops). Match the op class to your distance metric.(a, b) serves a or a AND b, not b alone.Tick each only if you can do it without looking: