Engineering Atlas › System Design Fundamentals

Making slow queries fast

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

From text to plan

The database parses the statement, rewrites it (expanding views, flattening simple subqueries), and hands a logical tree to the optimizer. The optimizer picks an access path per table (sequential, index, index-only, or bitmap scan), a join order, and a join algorithm (nested loop, hash, or merge). Join orders grow factorially, so planners use dynamic programming for small joins, an idea that goes back to System R (Selinger et al., 1979), and heuristics past a threshold (PostgreSQL switches to genetic search at 12 tables). A quieter limit bites first: Postgres only reorders explicit JOINs while the list stays within join_collapse_limit, 8 by default, so a 10-way join written with JOIN syntax keeps part of the order you wrote (Postgres docs).

Cost model and cardinality

Each plan node gets a cost from estimated page reads and per-row CPU work, with random reads priced above sequential ones. The decisive input is cardinality: how many rows each step yields. ANALYZE samples the table and stores null fraction, distinct count, a most-common-values list, and a histogram of the rest. The planner multiplies selectivities as if predicates were independent, which fails for correlated columns: city = 'Paris' AND country = 'FR' is underestimated by orders of magnitude. Multi-column statistics (CREATE STATISTICS) fix known correlated pairs (Postgres docs).

Why misestimates hurt

A nested loop with an indexed inner side is ideal when the outer side yields 10 rows. If reality is 500,000 rows, the same plan does 500,000 random probes where a hash join would read each side once. Errors compound up the tree, so a 10x miss at the bottom can become 10,000x at the top. The Join Order Benchmark study found that every major engine it tested routinely produced large cardinality errors on multi-join queries, and that these errors mattered far more to plan quality than the cost model did (Leis et al., VLDB 2015).

Indexes and query shapes

A B-tree on (tenant_id, created_at) serves WHERE tenant_id = ? ORDER BY created_at DESC LIMIT 50 with a short range scan and no sort, but does not help a filter on created_at alone. Wrapping a column in a function (lower(email)) hides it from a plain index. A covering index holding every selected column skips the table heap. Deep OFFSET pagination reads and discards every skipped row; keyset pagination (WHERE id > last_seen) does not. ORM N+1 patterns issue one query per parent row; batch them.

Down the stack

Layer 1The contract

The optimizer promises one thing: whatever plan it picks returns the same rows as the SQL you wrote. It promises nothing about speed, about plan stability across ANALYZE runs or version upgrades, or about row order unless you write ORDER BY. EXPLAIN shows estimates; EXPLAIN (ANALYZE, BUFFERS) runs the query and reports actual rows, loops, time per node, and pages hit in cache versus read. From Postgres 18, EXPLAIN ANALYZE includes the buffer counts without asking (Postgres 18 release notes). Even an unchanged table can get a different plan after ANALYZE, because each run draws a new random sample (Postgres docs). Anything you rely on beyond result equivalence, such as "this query uses that index", is an observation, not a guarantee.

Layer 2The mechanism

PostgreSQL keeps per-column statistics in pg_statistic (readable through pg_stats): null_frac, n_distinct, most_common_vals with most_common_freqs, histogram_bounds, and correlation between physical and logical order. ANALYZE samples 300 times default_statistics_target rows, so 30,000 rows at the default of 100, whatever the table size. Costs are in abstract units set by seq_page_cost (1.0), random_page_cost (4.0), cpu_tuple_cost (0.01), cpu_index_tuple_cost (0.005) and cpu_operator_cost (0.0025) (Postgres docs). Join order is searched with dynamic programming over subsets of relations until geqo_threshold (12), then by a genetic algorithm. Prepared statements get custom plans for the first five executions, then switch to a generic plan if its estimated cost is not much worse (Postgres docs), which is exactly where skewed parameters get hurt; plan_cache_mode = force_custom_plan turns the switch off. Postgres has no hint syntax of its own: forcing a join order or index takes the pg_hint_plan extension, which reads hints from a comment in the query (pg_hint_plan).

Layer 3The machine

Data lives in 8 KB pages. A page in shared_buffers costs well under a microsecond to reach; one in the OS page cache costs a syscall and a copy, a few microseconds; one on local NVMe about 100 µs; one on network block storage 0.5 to 2 ms. The default random_page_cost of 4 is not a disk measurement: the docs say a truly random read costs much more than four sequential ones, and 4 assumes most index reads hit cache. Many SSD deployments lower it toward 1.1, which makes index scans look cheaper and changes plans. Sorts get work_mem (4 MB by default) per node, and hash tables get work_mem times hash_mem_multiplier (2.0 by default, so 8 MB) (Postgres docs); past that they spill batches to temporary files, turning a 50 ms join into seconds of disk I/O. Index-only scans skip the heap only for pages marked all-visible in the visibility map, which vacuum sets, so a table that vacuum cannot keep up with quietly loses index-only scans.

Why it's built this way

  1. Random I/O costs much more than sequential I/O, so the planner needs a cost model that tells scans from index probes.
  2. Counting rows exactly would cost as much as running the query, so the planner estimates from a fixed-size sample.
  3. A sample cannot capture every correlation between columns, so the planner assumes independence and sometimes misses by orders of magnitude.
  4. Searching every join order is factorial, so exhaustive search stops at a threshold and heuristics take over.
  5. Planning itself costs CPU on every call, so plans are cached, and a cached plan can be wrong for the next parameter.

Diagram

flowchart LR
    Q["SQL text"] --> P[Parser]
    P --> R["Rewriter (views, subqueries)"]
    R --> O[Optimizer]
    S["Statistics (ANALYZE)"] --> O
    I["Index metadata"] --> O
    O --> PL["Lowest-cost plan"]
    PL --> E[Executor]
    E --> RES[Rows]
    E -.->|"estimated vs actual rows"| A["EXPLAIN ANALYZE"]

Worked example

An orders table holds 200 million rows (80 GB). The dashboard filters customer_id = ? and the last 30 days, ordered by created_at DESC LIMIT 20. With only an index on created_at, the planner walks the last 30 days of that index (about 6 million entries), fetching heap rows until it finds 20 matches. Active customers return in 3 ms; quiet ones take 4 seconds. "Only some users complain" is the signature.

CREATE INDEX ON orders (customer_id, created_at DESC) INCLUDE (total, status) turns this into an index-only scan of about 20 entries, with P99 under 2 ms for everyone. The cost is roughly 8 GB of index and one more B-tree update per insert, measurable WAL volume at 3,000 inserts per second. Usually worth it for a hot read path, but it is a trade.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Composite indexLarge, predictable speedupSlower writes, more storageA hot query has a stable shape
Rewrite the queryNo write cost, often the biggest winCode change; ORM may resistPlan shows avoidable sorts or scans
Better statisticsFixes many queries at onceUseless if no good plan existsEstimates and actuals diverge
Plan hintsImmediate, deterministicFreezes a plan that ages badlyEmergency mitigation
Cache or precomputeRemoves the query from the hot pathStaleness, invalidationExpensive and read often

Pitfalls in production

Leadership lens

Check yourself

  1. Why can a query become 1,000 times slower with no code or schema change?

    Show answer

    Data growth or skew shifts the statistics, the estimate crosses a threshold, and the planner picks an access path or join that is far worse for the real row counts.

  2. An index on (created_at, customer_id) exists, but a filter on customer_id alone still scans. Why?

    Show answer

    The B-tree is ordered by its leading column, so one customer's entries are scattered across the whole index.

  3. What does a large estimated-versus-actual gap in EXPLAIN ANALYZE tell you?

    Show answer

    Statistics are wrong for that predicate (stale, skewed, or correlated), so every plan choice above that node is suspect.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →