11 KiB
Performance — Plans, Indexes, and What To Change
Tuning order that avoids wasted work: rank queries by total cost (SKILL.md rule 9) → read the plan → fix the schema or the query → re-measure the same plan. Steps skipped in that order produce indexes nobody uses.
Contents: Plan Nodes · Join Algorithms · Statistics · Index Types · Composite Design · Sargability · Rewrites That Win · Aggregation · Sorting · Memory & Spills · Prepared Statements · Measuring · When Not To Tune
Plan Nodes: What Each One Tells You
| Node | Means | Change it by |
|---|---|---|
| Seq Scan / table scan | Whole table read | Adding a usable index — or accepting it if selectivity is above roughly 5-10% (SKILL.md rule 3) |
| Index Scan | Index walked, heap fetched per row | Making it index-only with INCLUDE/covering columns |
| Index Only Scan | No heap access | Watch Heap Fetches; high means the visibility map is stale — vacuum |
| Bitmap Heap Scan | Many index matches, heap read in page order | Normal for medium selectivity; if Rows Removed by Filter is high, widen the index |
| Nested Loop | Inner side executed per outer row | Cheap when the outer side is tiny AND the inner is indexed; catastrophic when the outer estimate was wrong |
| Hash Join | Build a hash of the smaller side | Fine for large unsorted joins; watch for Batches > 1 (spilled to disk) |
| Merge Join | Both sides sorted | Good when indexes already provide the order; expensive when it has to sort |
| Sort | Ordering rows | Provide the order via an index, or reduce rows first |
| Materialize / Memoize | Caching an inner result | Usually the planner compensating for repetition; not a bug |
| Aggregate vs HashAggregate | Grouping strategy | HashAggregate spilling means work_mem-class pressure (pg for tuning it) |
Read the plan bottom-up and inside-out: the deepest node runs first. In PostgreSQL, actual time on a node is per loop — multiply by loops for total. A node showing actual time=0.05..0.08 rows=1 loops=200000 costs 16 seconds, not 0.08 ms; missing this is the single most common plan misreading.
Join Algorithms: Choosing By Shape
- Nested loop wins when the outer row count is small (roughly hundreds) and the inner side has an index on the join key. It is also the failure mode of a bad estimate: the planner expected 10 outer rows, got 100,000, and now runs 100,000 index lookups.
- Hash join wins when one side fits in memory and neither is usefully sorted. Build side = the smaller estimated side; a wrong estimate hashes the wrong table.
- Merge join wins when both inputs arrive sorted from indexes; forcing it by adding an explicit sort rarely pays.
- Join order matters more than join algorithm on many-table queries. PostgreSQL exhaustively searches up to
join_collapse_limit(default 8) tables, then stops optimizing and uses the written order — a 12-table report is partly hand-ordered whether you meant it or not.
Statistics: Why The Planner Guessed Wrong
- Row estimates drive every decision. An estimate off by more than 10× invalidates the plan, not the index.
- After a bulk load, mass delete, or restore, statistics describe the previous shape.
ANALYZEis the first move, before any index. - Skewed columns: default histograms track a limited number of most-common values. A
statuscolumn where 99% is'done'needs more granularity — PostgreSQLALTER TABLE ... ALTER COLUMN ... SET STATISTICS 1000(default 100), MySQLANALYZE TABLE ... UPDATE HISTOGRAM ON col. - Correlated columns break the independence assumption:
WHERE city = 'Paris' AND country = 'France'is estimated assel(city) × sel(country), thousands of times too low. Declare it: PostgreSQLCREATE STATISTICS (dependencies) ON city, country FROM addresses(>=10). - Expression predicates have no statistics unless a matching expression index exists — creating the index improves the estimate as well as the access path.
Index Types Beyond B-tree
| Type | Use for | Cost |
|---|---|---|
| B-tree | Equality, ranges, sorting, prefix LIKE 'x%' |
The default; nothing else beats it for ordered data |
| Hash (PostgreSQL) | Equality only | No range, no sort; B-tree is nearly always fine instead |
| GIN | Array containment, JSONB @>, full-text |
Slow to update; batch writes or accept write amplification |
| GiST | Ranges, geometry, exclusion constraints | Supports overlap operators B-tree cannot |
| BRIN | Huge tables physically ordered by the indexed column (append-only time series) | Tiny index, but useless if physical order does not match the column |
| Full-text / FTS5 | Word and phrase search | Word-based, never substring |
Trigram (pg_trgm) |
Substring, fuzzy, LIKE '%x%', typo tolerance |
Large index; worth it exactly when B-tree cannot help |
Filtered/partial and covering variants apply to most of these — see SKILL.md Index Strategy for the syntax.
Composite Index Design
Order columns: equality first, then the one range/sort column, then included payload columns. Only the first range column can use the index for ordering; everything after it is filtered, not sought.
Worked example. Query: WHERE tenant_id = ? AND status = ? AND created_at > ? ORDER BY created_at DESC LIMIT 20.
(tenant_id, status, created_at)— correct. Both equalities seek,created_atprovides the range and the sort, so theLIMITstops after 20 rows.(created_at, tenant_id, status)— wrong. The index is ordered by time across all tenants; the engine scans time descending and discards other tenants' rows until it collects 20.(tenant_id, created_at, status)— half right. Seeks on tenant and time, butstatusis filtered per row: with 5% matching, the engine reads roughly 400 rows to return 20.
Rule of thumb for how many indexes: a write-heavy OLTP table carrying more than about five indexes is paying more on every insert than most read plans save. Consolidate — one composite often replaces three single-column indexes.
Sargability (Can The Predicate Use An Index)
Sargable: col = ?, col > ?, col BETWEEN ? AND ?, col IN (...), col LIKE 'prefix%', col IS NULL.
Not sargable, with the rewrite:
| Written | Rewrite |
|---|---|
WHERE YEAR(d) = 2024 |
WHERE d >= '2024-01-01' AND d < '2025-01-01' |
WHERE d::date = CURRENT_DATE |
WHERE d >= CURRENT_DATE AND d < CURRENT_DATE + 1 |
WHERE amount * 1.2 > 100 |
WHERE amount > 100 / 1.2 |
WHERE lower(email) = ? |
Expression index on lower(email), or store the normalized column |
WHERE col LIKE '%x%' |
Trigram index, or full-text if word-level is enough |
WHERE a = ? OR b = ? |
SELECT ... WHERE a = ? UNION ALL SELECT ... WHERE b = ? AND a <> ? |
WHERE col <> 'done' |
Partial index on the values you actually query, or list them with IN |
WHERE COALESCE(col, 0) > 5 |
WHERE col > 5 plus an explicit OR col IS NULL branch if needed |
Rewrites That Win
EXISTSoverIN (subquery)for large subqueries:EXISTSshort-circuits at the first match;INmay materialize the whole set.NOT EXISTSis also NULL-safe (SKILL.md rule 6).LIMITwith a covering index turns an ordered scan into an early stop. Without the matching order, the engine sorts everything then discards.- Window function instead of a correlated subquery per row: one pass with
ROW_NUMBER()replaces N executions. LATERALjoin for top-N-per-group at scale beatsROW_NUMBER()filtering when there are many groups and few rows per group, because it can stop per group.UNION ALLinstead ofUNIONwhen duplicates are impossible:UNIONsorts the whole result to deduplicate.- Push filters into subqueries and CTEs rather than filtering the outer result — with an optimization-fence CTE (PostgreSQL <12, or
MATERIALIZED), the outer predicate cannot reach inside. - Replace
OFFSETdeep pagination with keyset; this is a plan improvement of a different order than any index. - Batch N single-row statements into one multi-row statement: round-trip latency usually dominates execution time for small writes.
Aggregation Performance
- Pre-aggregate before joining whenever a 1:N join feeds a
SUM/COUNT— it also fixes the fan-out correctness bug (SKILL.md Traps). COUNT(*)on a large table is a scan in PostgreSQL (MVCC visibility must be checked per row) but O(1) in MyISAM and cheap in SQL Server. Use the planner estimate for "about how many".GROUP BYon a low-cardinality column can be served from an index;GROUP BYon a high-cardinality expression cannot.- Materialized views trade freshness for latency; incremental rollup tables trade complexity for both.
DISTINCTandGROUP BYon the same columns usually produce the same plan — the readability choice is free.
Sorting
- An index provides order only if the
ORDER BYmatches its column order AND direction pattern. Mixed directions (a ASC, b DESC) need an index declared that way, which PostgreSQL and SQL Server support and SQLite supports from 3.3. - Sorting the full result then applying
LIMITis a top-N heap sort in most engines — much cheaper than a full sort, but still touches every row. Only an index removes that. - NULL sort position differs by engine; adding
NULLS LASTto a query can disable an index that does not carry that ordering.
Memory and Spills
- A sort or hash that exceeds the per-node work memory spills to disk. In PostgreSQL,
EXPLAIN ANALYZEreportsSort Method: external merge Disk: NkB— that string is the direct signal. - The budget is per node, not per query: a query with four sorts uses four allocations, and every connection can do this at once. Raising the global value multiplies by connections (
pgfor the sizing formula). - Cheaper than more memory: fewer rows entering the sort (filter earlier), or an index that supplies the order.
Prepared Statements and Plan Caching
- A prepared statement may switch to a generic plan after several executions. With skewed data, the generic plan is right on average and wrong for the parameter you care about.
- Symptom: identical SQL is fast in a console with literals, slow from the application.
- Fixes in order: keep parameters out of highly skewed predicates, split the query into per-branch statements, or force a custom plan per execution (
plan_cache_modein PostgreSQL >=12;OPTION (RECOMPILE)in SQL Server). - Server-side prepared statements also interact with transaction-mode connection poolers.
Measuring Honestly
- Warm the cache: run the query twice and compare the second run against the second run of the alternative. First runs measure disk, not the change.
- Compare buffers/reads, not just wall time — a shared machine's wall time is noise.
- Change one thing at a time, and re-run
EXPLAIN ANALYZEafter each. Two changes at once means you keep an index you did not need. - Verify on production-shaped data volumes. A plan chosen over 1,000 rows tells you nothing about 10 million.
- After deploying an index, confirm it is actually used (
pg_stat_user_indexes.idx_scanclimbing) and that write latency did not regress.
When Not To Tune
- The query runs once a month and takes 40 seconds. Cost of tuning exceeds cost of waiting.
- The table has 5,000 rows. Full scans of small tables are free; adding indexes there adds write cost and clutter.
- The real fix is upstream: pagination the UI does not need, a report that should be a nightly rollup, or an N+1 the ORM emits.
- You are at the limit of one node rather than one query — that is a topology problem.