Module 3 · Postgres & Data · Deep Dive

Postgres Performance & Optimization

Reading query plans, indexing for real, killing N+1, pooling connections, and making DocChat's retrieval fast. "This endpoint is slow — fix it" is the most heavily-probed backend interview ground for a 2026 full-stack role. This is how you own it.

IntermediateAdvancedBuild

Why this matters The advanced-SQL lesson gave you the queries; this one makes them fast and proves it. On the job, the database is where your app dies first: a missing index turns a 5 ms lookup into a 5 second sequential scan, an N+1 fires 200 queries to render one page, and a connection storm takes the whole service down. In the interview, "how would you debug a slow query?" and "what's the N+1 problem?" are near-certain. By the end you'll read an EXPLAIN ANALYZE like a doctor reads an X-ray, pick the right index every time, and have made DocChat's "find relevant chunks" query genuinely fast — HNSW vector index and all.
In this lesson
  1. Reading query plans with EXPLAIN
  2. Indexes in depth
  3. Selectivity — why an index gets ignored
  4. The N+1 problem & SQLAlchemy 2.0
  5. Connection pooling
  6. JSONB & full-text vs vector search
  7. VACUUM, dead tuples & partitioning
  8. Locking & keyset pagination
  9. Build: make DocChat retrieval fast
  10. Check yourself

1 · Reading query plans with EXPLAIN

You met EXPLAIN in the advanced lesson; here we go deeper, because reading a plan well is the single most useful database skill you can demonstrate. EXPLAIN shows the plan Postgres intends to use, with cost estimates, without running the query. EXPLAIN ANALYZE actually executes it and adds real timings and real row counts. For tuning, almost always reach for EXPLAIN (ANALYZE, BUFFERS)BUFFERS shows how many disk/cache pages each node touched, which is often the real story.

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM documents WHERE owner_id = 42;

A plan is a tree of nodes, read inside-out and bottom-up: the deepest, most-indented node runs first, feeding its parent. Each node line looks like this:

Seq Scan on documents  (cost=0.00..2310.00 rows=118 width=96)
                       (actual time=0.30..41.2 rows=120 loops=1)
  Filter: (owner_id = 42)
  Rows Removed by Filter: 99880

Decode it field by field:

FieldWhat it tells you
cost=0.00..2310.00Estimated startup..total cost in arbitrary units. The first number is cost-to-first-row; the second is cost-to-last-row. Compare nodes — high total cost is where the planner expects pain.
rows=118 (estimate) vs actual rows=120The planner's guess vs reality. A big gap (estimate 100, actual 1,000,000) means stale statistics — run ANALYZE documents; — and is the root cause of many bad plans.
actual time=0.30..41.2Real milliseconds, first-row..last-row, per loop. This is the truth. Find the node with the biggest actual time — that's your bottleneck.
loops=1How many times this node ran. Multiply actual time × loops for the real total — a cheap node looped 10,000 times (the N+1 signature) is expensive.
Rows Removed by FilterRows read then thrown away. A huge number here means you scanned a lot to keep a little — a selective filter screaming for an index.

Scan types the three you must recognise

So the planner has a spectrum: few matches → Index Scan; some → Bitmap; most → Seq Scan. When you see a Seq Scan you didn't expect, the question is always: is the filter selective, and is there an index it can use?

Interview answer · "Walk me through debugging a slow query"

Name the loop, name the tools — this is a script you should be able to recite:

  1. Reproduce with real parameters and run EXPLAIN (ANALYZE, BUFFERS) — the actual plan and timings, not a guess.
  2. Find the node with the largest actual time × loops. Usually a Seq Scan over a big table, a wildly wrong rows estimate, or a sort spilling to disk.
  3. Diagnose: selective filter with no index → add an index. Estimate far off actual → ANALYZE the table. Same query fired N times → an N+1 from the ORM.
  4. Re-run EXPLAIN ANALYZE and confirm the scan flipped (Seq → Index/Bitmap) and the time dropped. Measure, never assume.

The phrase that lands: "I let EXPLAIN ANALYZE tell me where the time goes before I change anything."

2 · Indexes in depth

An index is a sorted side-structure that lets Postgres find rows without scanning the table. The default and the workhorse is the B-tree — perfect for equality (=) and range (<, BETWEEN, ORDER BY) on scalar columns. The art is choosing the right index for the query shape.

Composite indexes — column order is everything

CREATE INDEX idx_chunks_doc_idx
ON chunks (document_id, chunk_index);

A multi-column index is sorted by the first column, then the second within each value of the first. The leftmost-prefix rule: this index serves a filter on document_id, or on document_id AND chunk_index — but not on chunk_index alone, because that column is only ordered inside each document. Rule of thumb: equality columns first, then the range/sort column last. A query like WHERE owner_id = 42 ORDER BY created_at DESC wants (owner_id, created_at DESC) so the index satisfies both the filter and the sort.

Partial indexes — index only the rows you query

-- you only ever search live documents
CREATE INDEX idx_docs_owner_live
ON documents (owner_id)
WHERE deleted_at IS NULL;

The WHERE on the index definition means Postgres indexes only matching rows — smaller, faster to scan, cheaper to maintain on every write. Ideal when queries always carry the same condition (WHERE deleted_at IS NULL, WHERE status = 'active').

Covering indexes — INCLUDE to skip the table

CREATE INDEX idx_docs_owner_inc
ON documents (owner_id) INCLUDE (title, created_at);

Normally an Index Scan finds the row in the index, then visits the heap to fetch the other columns. If every column the query needs lives in the index, Postgres can answer from the index alone — an Index Only Scan, no heap visit. INCLUDE bolts extra columns onto the index leaf without making them part of the sort key. When you see Index Only Scan in a plan, you've nailed it.

GIN — for JSONB & full-text

-- fast metadata @> containment on a big table
CREATE INDEX idx_docs_metadata
ON documents USING GIN (metadata);

B-trees index scalar values. For "does this JSONB contain that key/value?" or "does this text match these terms?" you need GIN (Generalized Inverted Index) — it indexes the contents of composite values, mapping each element back to the rows holding it. It's the right index for jsonb @> queries and for tsvector full-text (both below).

HNSW — for pgvector similarity

-- approximate nearest-neighbour over embeddings (pgvector 0.8, PG 16/17)
CREATE INDEX idx_chunks_embedding
ON chunks USING hnsw (embedding vector_cosine_ops);

Vector search ("which chunks are semantically closest to this question?") can't use a B-tree — there's no useful linear order in 1,536 dimensions. HNSW (Hierarchical Navigable Small World) is a graph index that finds approximate nearest neighbours fast. As of 2026 it's the default choice over the older IVFFlat: better recall, no training step, robust as data grows. Match the operator class to your distance metric — vector_cosine_ops for cosine (<=>), vector_l2_ops for Euclidean (<->). We build exactly this for DocChat below.

The cost of an index Indexes aren't free. Every INSERT/UPDATE/DELETE must also update every index on the table, so over-indexing slows writes and wastes disk. The discipline: index the columns you actually filter, join, and sort on in hot queries — then stop. Use pg_stat_user_indexes to find indexes with zero scans and drop them.

3 · Selectivity — why a perfect index gets ignored

A common shock: you add the obvious index and Postgres still does a Seq Scan. It's usually not a bug — the planner decided the index would be slower. The governing idea is selectivity: what fraction of the table the filter keeps.

An Index Scan pays a random-access cost per matching row (find it in the index, jump to the heap). If your filter matches 60% of the table, doing that 600,000 times is far slower than reading the whole table sequentially once. So when a filter isn't selective, a Seq Scan is genuinely the faster plan — and the planner is right.

Other reasons an index is skipped — each shows up in EXPLAIN:

Interview: "You added an index and the query didn't speed up — why?" Strong answer: the filter probably isn't selective, so a Seq Scan is cheaper and the planner chose it; or the column is wrapped in a function / has a leading wildcard so the index can't be used; or stats are stale. I'd confirm with EXPLAIN rather than guess.

4 · The N+1 problem & SQLAlchemy 2.0

The N+1 query problem is the most common ORM performance bug, and a near-guaranteed interview question. You run 1 query to fetch a list of parents, then — usually inside a loop — N more queries, one per parent, to fetch each one's children. One page render becomes hundreds of round-trips.

In DocChat, loading documents and listing each one's chunks:

from sqlalchemy import select

# 1 query: fetch the documents
docs = session.scalars(select(Document)).all()

for doc in docs:
    # N queries: each .chunks access fires a fresh SELECT!
    print(doc.title, len(doc.chunks))

20 documents → 21 queries. In an EXPLAIN-style trace you'd see the same child query with loops=20, or in the SQL log the identical SELECT … FROM chunks WHERE document_id = ? repeated. The fix in SQLAlchemy 2.0 is an eager-loading strategy passed to select() via .options():

from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload

# selectinload: ONE extra query loads ALL chunks via WHERE document_id IN (...)
stmt = select(Document).options(selectinload(Document.chunks))
docs = session.scalars(stmt).all()   # 2 queries total, regardless of N

Two strategies, and knowing when to use each is the real answer:

StrategyHow it worksUse when
selectinloadEmits a second query: … WHERE document_id IN (id1, id2, …). Always 2 queries total.One-to-many (a doc's many chunks). The default choice — no row duplication, scales cleanly.
joinedloadA single query with a JOIN, assembling parent + children in one result set.Many-to-one / one-to-one (a chunk's one document). One round-trip, no fan-out.

Why not always joinedload? On a one-to-many it multiplies rows — 20 docs × 50 chunks = 1,000 rows, the parent columns repeated 50 times each, then de-duplicated in Python. selectinload avoids that fan-out. Rule: selectinload for collections, joinedload for single related objects.

Lazy loading is the trap SQLAlchemy's default is lazy loading: a relationship isn't fetched until you touch it — which is exactly what fires the N+1, often invisibly inside a Jinja template or a serializer. The fix is to be deliberate: decide what you'll need and eager-load it in the select(). Turn on echo=True on your engine in dev and watch the queries — the N+1 will be staring at you.
PHP bridge: identical to Eloquent's N+1. selectinload->with('chunks') (eager load), and lazy access ≈ Eloquent's lazy relationships firing a query per model. Same disease, same cure: load the relation up front.

5 · Connection pooling

Opening a Postgres connection is expensive — a TCP handshake, authentication, a backend process forked on the server. Doing that per request would crush you, and Postgres has a hard max_connections ceiling (often ~100). A connection pool keeps a set of open connections and hands them out and back, so requests reuse warm connections instead of paying setup each time.

SQLAlchemy pools by default. Tune it on the engine:

from sqlalchemy import create_engine

engine = create_engine(
    DB_URL,
    pool_size=10,        # persistent connections kept open
    max_overflow=5,      # temporary extras under burst (so 15 max)
    pool_timeout=30,     # seconds to wait for a free conn before erroring
    pool_recycle=1800,   # recycle conns older than 30 min (avoid stale ones)
    pool_pre_ping=True,  # test a conn is alive before handing it out
)

The key relationship to state in an interview: pool_size + max_overflow, multiplied by the number of app processes/workers, must stay under Postgres' max_connections. Four Gunicorn workers each allowed 15 connections = 60, plus headroom for migrations and admin tools.

Serverless needs pgbouncer SQLAlchemy's in-process pool assumes a long-lived process. Serverless functions (Lambda, Vercel) spin up many short-lived instances that can't share a pool — each cold start opens its own connections and you blow past max_connections instantly. The fix is an external pooler: pgbouncer in transaction mode, sitting between your functions and Postgres, multiplexing thousands of client connections onto a small set of real ones. Managed Postgres (Supabase, Neon, RDS Proxy) ships this for you. In transaction mode, disable SQLAlchemy's own pooling (poolclass=NullPool) and turn off server-side prepared statements.

6 · JSONB & full-text vs vector search

You met JSONB in the advanced lesson. Here's the performance angle plus the search question DocChat actually faces. JSONB stores parsed binary JSON — perfect for semi-structured data that doesn't deserve its own columns (per-document metadata, flexible settings). The operators that matter:

SELECT metadata ->> 'source'            -- field as text:  pdf
FROM documents
WHERE metadata @> '{"source":"pdf"}';  -- "contains" — indexable by GIN

The performance trap is the same as before: if you @> the same key on every request, it deserves its own typed, B-tree-indexed column. JSONB is for genuinely variable data, not for dodging schema design.

Full-text search tsvector & tsquery

For keyword search, LIKE '%term%' is a Seq Scan and can't rank. Postgres' full-text search tokenises text into a tsvector (normalised, stemmed lexemes) and matches it against a tsquery:

-- match documents containing "retrieval" & "vector", ranked
SELECT id, title,
       ts_rank(search_vec, query) AS rank
FROM documents, to_tsquery('english', 'retrieval & vector') query
WHERE search_vec @@ query
ORDER BY rank DESC;

-- index the tsvector with GIN to make @@ fast
CREATE INDEX idx_docs_fts
ON documents USING GIN (search_vec);

@@ is the match operator. Store the tsvector as a generated column so it stays in sync, and GIN-index it. Stemming means a search for "running" also matches "run".

Interview answer · full-text vs vector search (and using both)

Full-text matches literal words (after stemming) — great for exact terms, names, codes, error strings. Vector search (pgvector + HNSW) matches meaning — "how do I reset my password" finds a chunk titled "account recovery" with no shared words. Neither is strictly better.

The modern RAG answer is hybrid search: run both, then combine the rankings (commonly Reciprocal Rank Fusion). Full-text catches the exact keyword the user typed; vector catches the semantically-related passages that share no keywords. DocChat does keyword filtering with full-text and semantic recall with HNSW, fusing the two for the best context.

7 · VACUUM, dead tuples & partitioning

Because of MVCC (from the advanced lesson), an UPDATE or DELETE doesn't remove the old row — it marks it as a dead tuple, a version no live transaction can see. Left alone, dead tuples accumulate as bloat: the table grows on disk, scans read more pages, performance sags.

VACUUM reclaims dead tuples so their space can be reused; ANALYZE refreshes the statistics the planner relies on. Autovacuum runs both automatically in the background — but on a hot, churn-heavy table it can fall behind. Signs to know:

-- check dead tuples and last (auto)vacuum per table
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

Partitioning for very large tables

When a table reaches tens or hundreds of millions of rows, partitioning splits it into smaller physical child tables under one logical parent — commonly by date range. Queries that filter on the partition key let Postgres skip whole partitions (partition pruning), and you can drop old data by dropping a partition instead of a slow mass DELETE.

CREATE TABLE events (id bigint, created_at timestamptz, payload jsonb)
  PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_06 PARTITION OF events
  FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

It's a tool for genuinely large or time-series tables — don't reach for it at 100,000 rows; a good index is all you need there.

8 · Locking basics & keyset pagination

Two practical performance topics that bite real apps.

Locking the minimum you must know

Thanks to MVCC, reads never block writes and vice versa. But two transactions updating the same row serialise — the second waits for the first to commit. When you need to read a row and then update it safely under concurrency, lock it explicitly:

BEGIN;
SELECT credits FROM accounts WHERE id = 7 FOR UPDATE;  -- row-lock
-- decide, then …
UPDATE accounts SET credits = credits - 1 WHERE id = 7;
COMMIT;

FOR UPDATE locks the selected rows until commit so no one else can change them mid-decision. Remember the deadlock cure from the advanced lesson: consistent lock ordering and short transactions.

Keyset pagination why OFFSET gets slow

The obvious "page 500" query — LIMIT 20 OFFSET 10000 — forces Postgres to read and discard the first 10,000 rows before returning 20. Cost grows linearly with the offset, so deep pages crawl. The fix is keyset (a.k.a. cursor or seek) pagination: instead of an offset, remember the last row you saw and ask for rows after it.

-- SLOW: OFFSET reads then throws away 10,000 rows
SELECT id, title, created_at FROM documents
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;

-- FAST: keyset — seek straight to "after the last row I saw"
SELECT id, title, created_at FROM documents
WHERE (created_at, id) < ('2026-06-01 09:00+04', 8421)
ORDER BY created_at DESC, id DESC
LIMIT 20;

The keyset query uses the index on (created_at DESC, id DESC) to jump directly to the start of the page — same speed for page 1 or page 50,000. The trade-off: you can only go next/previous, not jump to an arbitrary page number. For infinite-scroll and "load more" feeds (DocChat's chat history, a document list), keyset is the right call. Include a unique tie-breaker (id) so rows with equal timestamps page deterministically.

Interview: "Why is OFFSET pagination slow at high offsets?" Because the database must scan and discard every row before the offset on each request, so cost grows with the page depth. Keyset pagination filters by the last-seen sort key (WHERE (created_at, id) < (…)) and uses the index to seek, giving constant time per page — at the cost of only supporting sequential navigation.

9 · Build: make DocChat retrieval fast

Your tangible win Take DocChat's slow "find relevant chunks" query, profile it with EXPLAIN ANALYZE, add the right indexes (an HNSW index on the embedding and a GIN index on JSONB metadata), prove the speedup, then kill the N+1 when loading documents-with-chunks. This is the exact tuning loop you'll run on the job.

The retrieval query: given a question's embedding, find the 5 nearest chunks that belong to PDF-sourced documents.

-- before: no vector index, no metadata index
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.id, c.content,
       c.embedding <=> '[0.01, -0.02, …]' AS distance
FROM chunks c
JOIN documents d ON d.id = c.document_id
WHERE d.metadata @> '{"source":"pdf"}'
ORDER BY distance
LIMIT 5;

The "before" plan is grim: a Seq Scan on chunks computing the distance for every chunk, a Seq Scan on documents for the metadata filter, then a Sort over all of it — actual time in the hundreds of milliseconds and climbing with the table. Add the two indexes:

-- HNSW for approximate nearest-neighbour on the embedding
CREATE INDEX idx_chunks_embedding
ON chunks USING hnsw (embedding vector_cosine_ops);

-- GIN for the JSONB metadata containment filter
CREATE INDEX idx_docs_metadata
ON documents USING GIN (metadata);

ANALYZE chunks;
ANALYZE documents;

Re-run the EXPLAIN ANALYZE. Now the plan shows an Index Scan using idx_chunks_embedding (HNSW walking the graph to the 5 nearest, no full sort) and a Bitmap Index Scan on the metadata GIN index — actual time drops from hundreds of ms to single digits. You can trade recall for speed with SET hnsw.ef_search = 100; (higher = more accurate, slightly slower). That before/after pair — plan and timing — is exactly what you show an interviewer.

Now the application side. The route that lists documents with their chunk counts had an N+1; fix it in SQLAlchemy 2.0:

app/retrieval.py
from sqlalchemy import select
from sqlalchemy.orm import selectinload

def list_documents_with_chunks(session):
    # one-to-many → selectinload: 2 queries total, no N+1, no fan-out
    stmt = (
        select(Document)
        .options(selectinload(Document.chunks))
        .order_by(Document.created_at.desc())
    )
    return session.scalars(stmt).all()

With echo=True you'll see exactly two queries: one for documents, one … WHERE document_id IN (…) for all their chunks — no matter how many documents. You've made the database fast and the ORM honest. That's the whole job.

10 · Check yourself

Answer from memory — retrieval is what moves this from "I read it" to "I know it".

Recall quiz

A big table shows a Seq Scan for a filter that matches few rows. The likely cause?

For loading a document's many chunks, which avoids N+1 without row fan-out?

Which index type powers pgvector nearest-neighbour search in 2026?

Why does LIMIT 20 OFFSET 10000 get slow?

An UPDATE-heavy table is bloated and scans are slow. What's accumulating?

Primary source ⭐ PostgreSQL Docs — Using EXPLAIN is the authoritative reference for §1. For indexes and the leftmost-prefix rule, Use The Index, Luke is the definitive free guide. For the vector side, the pgvector README documents HNSW indexing and the distance operators.