Module 3 · Postgres & Data · Drills

Drills: Postgres Performance

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.

How to use this page Each drill is a small diagnosis or a query to write. Commit to an answer first — say why the plan is slow, or write the index — then click "Show solution" to compare. If you have a real Postgres handy, run EXPLAIN ANALYZE on the before/after and watch the timing move. Tick each box as you go; progress is saved in this browser.

A · Read the plan Basic

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
Show solution

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?

Show solution

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?

Show solution
  • (a) 5 rows → Index Scan — very selective, few heap fetches.
  • (b) 900K rows → Seq Scan — not selective; reading the whole table once beats millions of random index lookups.
  • (c) ~20K rows → Bitmap Heap Scan — the middle ground: build a bitmap from the index, then read the heap in physical order.

The lesson: selectivity drives the choice. An index doesn't help a non-selective filter, and the planner is right to ignore it.

B · Choose the right index Intermediate

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;
Show solution
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.

Show solution
-- (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?

Show solution

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.

C · Fix the application Intermediate

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!
Show solution
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;
Show solution
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.

D · Build challenge Build

Mini-project Take DocChat's slow retrieval query, profile it, index it, and prove the win with a before/after plan. This is the exact deliverable an interviewer or a senior engineer asks for: "show me it's actually faster."

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;
Show solution

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.

E · Rapid recall Flashcards

Tap a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.

Seq Scan on a big table with a selective filter means?
A missing or unusable index — Postgres is reading every row to keep a few. Check Rows Removed by Filter.
tap to flip
selectinload vs joinedload?
selectinload for one-to-many (2 queries, no fan-out); joinedload for many-to-one / one-to-one (single JOIN).
tap to flip
Index for pgvector nearest-neighbour?
HNSW: USING hnsw (embedding vector_cosine_ops). Match the op class to your distance metric.
tap to flip
Why is OFFSET 10000 slow?
It reads and discards all 10,000 skipped rows each request. Keyset pagination seeks via the index instead — constant time per page.
tap to flip
Composite index column order rule?
Equality column(s) first, range/sort column last. Leftmost-prefix: (a, b) serves a or a AND b, not b alone.
tap to flip
What are dead tuples?
Old row versions left by UPDATE/DELETE under MVCC. They cause bloat; VACUUM (autovacuum) reclaims them.
tap to flip

F · Self-check before moving on

Tick each only if you can do it without looking:

Next All ticked? You can now debug a slow query, choose the right index, and prove a speedup — the most heavily-probed backend interview ground. Keep going through the course to the next topic.