Database Performance Tuning
You are the database engineer on call. Someone says "it's slow". Your job is to turn
that into a number, find where the time goes, change the smallest thing that moves the
number, and prove it moved.
Boundary with other skills
- This skill: why is it slow and what do I change — measurement, plans, indexes,
rewrites, vacuum, bloat, safe rollout.
postgresql-optimization: Postgres features — JSONB operators, arrays, ranges,
full-text search, window functions. Reach for it when the question is "how do I express
this in Postgres", not "why is this slow".
postgres-game-schema, neon-postgres, supabase: schema design and platform
specifics. If the fix is a new index or a rewrite, stay here.
The loop
Measure → get the plan → classify → change one thing → verify. Skipping straight to
"add an index" is the single most common way to make a database slower: every index costs
write throughput, bloats WAL, and slows vacuum. Earn each one.
0. Establish the ground truth before anything else
Facts in this domain are version-gated. Column names, EXPLAIN options and planner
behaviour all changed in recent majors, so the first thing you run is:
SELECT version();
SELECT name, setting FROM pg_settings
WHERE name IN ('shared_buffers','work_mem','effective_cache_size',
'random_page_cost','track_io_timing','max_parallel_workers_per_gather');
SELECT extname, extversion FROM pg_extension;
Write the major version down and keep it in mind for the rest of the session. If
pg_stat_statements is not installed you are flying blind — say so and ask before
installing it, since it needs shared_preload_libraries and a restart.
Version notes you will actually trip over (checked against the PostgreSQL 18 manual,
which is docs/current as of August 2026):
| Thing |
Rule |
pg_stat_statements.total_exec_time |
PG 13+. Older servers call it total_time. |
shared_blk_read_time / shared_blk_write_time |
PG 17+. PG 16 and older call them blk_read_time / blk_write_time. |
local_blk_read_time, stats_since, minmax_stats_since |
PG 17+. |
wal_buffers_full, parallel_workers_to_launch, parallel_workers_launched |
PG 18+. |
EXPLAIN (ANALYZE) includes buffers automatically |
PG 18+. On PG 17 and older you must write EXPLAIN (ANALYZE, BUFFERS). |
EXPLAIN (SERIALIZE, MEMORY) |
PG 17+. |
EXPLAIN (GENERIC_PLAN) |
PG 16+. |
| B-tree skip scan (leading column can be omitted) |
PG 18+. On older majors a composite index is unusable without its leading column. |
When you are unsure whether a feature exists on the server in front of you, ask the
server rather than guessing — \d pg_stat_io, SELECT ... FROM pg_extension, or read
the manual for that exact major.
1. Find where the time actually goes
Run the bundled read-only diagnostic instead of hand-rolling the queries — it adapts its
SQL to the server version and covers the whole picture in one pass:
<skill-dir>/scripts/pg-diagnose.sh "$DATABASE_URL"
<skill-dir> is the base directory printed when this skill loads — the script lives
next to this file, not in the user's project. Options go before the connection string
(-n 30 for more rows per section, -q to skip the pg_stat_statements sections).
It reports: top statements by total and by mean time, cache hit ratios, tables taking
sequential scans, unused and duplicate and invalid indexes, dead-tuple and autovacuum lag,
bloat estimates, and current blocking locks. Read
references/diagnose-postgres.md when you need to go
past what the script prints, or to interpret what it printed.
Rank by total time, not by mean. A 5 ms query called two million times an hour costs
more than a 3-second report run once. Both matter, but they get different fixes.
2. Get the real plan
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT TEXT) <query>;
ANALYZE executes the statement. Wrap writes in a transaction you roll back, and never
run it against production on a statement whose side effects you have not read.
Read the plan bottom-up and look at three things per node: the ratio between estimated
and actual rows, the buffers, and loops. actual time is per loop — multiply it out
before you believe a node is cheap. references/explain-plans.md
has the node-by-node reading guide and the red flags.
3. Classify before you fix
Match the symptom to the cause. Most slow queries are one of these:
| Symptom in the plan |
Likely cause |
Where to go |
| Seq Scan on a large table with a selective filter |
missing or unusable index |
index-design |
| Index Scan present but still slow, high heap buffers |
index not covering, or high correlation loss |
index-design |
| Estimated rows off by 10x or more |
stale or insufficient statistics, correlated columns |
maintenance |
Filter discards most rows (Rows Removed by Filter) |
predicate not sargable, or wrong column order |
query-rewrites |
Nested Loop with a huge loops count |
bad row estimate upstream, or an N+1 from the app |
query-rewrites |
Sort or Hash spilling to disk (Sort Method: external merge) |
work_mem too low for this shape |
maintenance |
| Same query fast on a fresh copy, slow in prod |
bloat, or a plan flip from parameter sniffing |
maintenance |
| Query waits, plan itself is fine |
lock contention |
diagnose-postgres |
Say out loud which one you picked and what evidence picked it. If two fit, you have not
measured enough yet.
4. Change one thing
One index, or one rewrite, or one setting — never a batch. A batch that improves things
teaches you nothing about which part did it, and a batch that regresses is hard to unwind.
Every index change on a live system goes through
references/apply-safely.md: CREATE INDEX CONCURRENTLY,
a lock_timeout, a rollback path, and the prod-api-safety protocol before the first
write. CREATE INDEX CONCURRENTLY cannot run inside a transaction block, does two table
scans, and leaves an INVALID index behind if it fails — that reference covers the
cleanup.
Before adding an index, check that an existing one cannot be extended instead. Two
indexes on (a) and (a, b) mean the first is usually dead weight.
5. Verify with the same number you started with
Re-run the exact measurement from step 1 and put the before and after side by side:
tenant dashboard query
before: 1,240 ms mean, 38,400 shared blocks read, Seq Scan on orders
after: 12 ms mean, 41 shared blocks read, Index Scan using orders_tenant_created_idx
index: orders (tenant_id, created_at DESC) — 240 MB, built CONCURRENTLY in 4m12s
Report writes too, not just the read you fixed: an index that made a report 100x faster
and inserts 20% slower is a trade, and the person paying for it should see both sides.
SELECT pg_stat_statements_reset() before an after-measurement, and note that you did.
Never report an improvement you have not re-measured on the same workload. A faster
EXPLAIN ANALYZE on a warm cache is not a production win.
Standing rules
- Read-only until proven otherwise. Diagnosis never needs write access. Connect with a
read-only role, or
SET default_transaction_read_only = on, and keep it that way until
you have a specific change to apply.
- The database may not be the problem. Check whether the time is in the query, the
round trips, the connection pool, or serialization to the client.
EXPLAIN (SERIALIZE)
on PG 17+ tells you how much is output conversion.
- Never
SET a global on a live server to test a hypothesis. Use a session-level
SET in your own connection, or EXPLAIN with the setting changed locally.
- Don't trust a plan from a different data distribution. Staging with 10k rows will
happily choose a plan production would never pick.
ANALYZE after any bulk load or big migration, before you judge anything.
References
Read the one that matches what you are doing. They are written to be opened mid-task.
| File |
Read it when |
| references/diagnose-postgres.md |
Collecting evidence: pg_stat_statements, table and index stats, waits, locks, connections, bloat. |
| references/explain-plans.md |
Reading an EXPLAIN output node by node and spotting the red flags. |
| references/index-design.md |
Choosing the index: column order, partial, covering, expression, GIN/GiST/BRIN/hash, and when not to. |
| references/query-rewrites.md |
The query itself is the problem: sargability, N+1, pagination, OR, EXISTS, LATERAL, CTEs, window functions. |
| references/maintenance.md |
Vacuum, autovacuum tuning, bloat, statistics targets, extended statistics, partitioning, fillfactor, reindex. |
| references/apply-safely.md |
Applying a change to a live database without taking an outage. |
| references/mysql.md |
The server is MySQL or MariaDB — InnoDB specifics, EXPLAIN ANALYZE, sys schema, index dives. |
1---2name: database-performance-tuning3description: Diagnose and fix slow databases the way a DBA does — measure first, read the plan, then change one thing. Use this skill whenever a query, endpoint, job or report is slow, times out, or got slower after a deploy; whenever someone asks about indexes (missing, unused, duplicate, composite order, partial, covering, GIN/BRIN), EXPLAIN or query plans, seq scans, N+1 queries, slow pagination, table or index bloat, autovacuum, VACUUM, ANALYZE, stale statistics, connection or lock pileups, high CPU/IO on the database, partitioning, or a migration that locked a table. Also trigger on Portuguese phrasings: 'query lenta', 'consulta lenta', 'banco lento', 'timeout no banco', 'falta índice', 'criar índice', 'travou a tabela', 'lock', 'vacuum', 'plano de execução'. Postgres is the core; MySQL/InnoDB is covered in references/mysql.md. Use it even when the user just says 'this page is slow' and the data comes from a database — the first job of this skill is to find out whether the database is actually the problem.4---56# Database Performance Tuning78You are the database engineer on call. Someone says "it's slow". Your job is to turn9that into a number, find where the time goes, change the smallest thing that moves the10number, and prove it moved.1112## Boundary with other skills1314- This skill: **why is it slow and what do I change** — measurement, plans, indexes,15 rewrites, vacuum, bloat, safe rollout.16- `postgresql-optimization`: Postgres **features** — JSONB operators, arrays, ranges,17 full-text search, window functions. Reach for it when the question is "how do I express18 this in Postgres", not "why is this slow".19- `postgres-game-schema`, `neon-postgres`, `supabase`: schema design and platform20 specifics. If the fix is a new index or a rewrite, stay here.2122## The loop2324Measure → get the plan → classify → change one thing → verify. Skipping straight to25"add an index" is the single most common way to make a database slower: every index costs26write throughput, bloats WAL, and slows vacuum. Earn each one.2728### 0. Establish the ground truth before anything else2930Facts in this domain are version-gated. Column names, EXPLAIN options and planner31behaviour all changed in recent majors, so the first thing you run is:3233```sql34SELECT version();35SELECT name, setting FROM pg_settings36WHERE name IN ('shared_buffers','work_mem','effective_cache_size',37 'random_page_cost','track_io_timing','max_parallel_workers_per_gather');38SELECT extname, extversion FROM pg_extension;39```4041Write the major version down and keep it in mind for the rest of the session. If42`pg_stat_statements` is not installed you are flying blind — say so and ask before43installing it, since it needs `shared_preload_libraries` and a restart.4445Version notes you will actually trip over (checked against the PostgreSQL 18 manual,46which is `docs/current` as of August 2026):4748| Thing | Rule |49| --- | --- |50| `pg_stat_statements.total_exec_time` | PG 13+. Older servers call it `total_time`. |51| `shared_blk_read_time` / `shared_blk_write_time` | PG 17+. PG 16 and older call them `blk_read_time` / `blk_write_time`. |52| `local_blk_read_time`, `stats_since`, `minmax_stats_since` | PG 17+. |53| `wal_buffers_full`, `parallel_workers_to_launch`, `parallel_workers_launched` | PG 18+. |54| `EXPLAIN (ANALYZE)` includes buffers automatically | PG 18+. On PG 17 and older you must write `EXPLAIN (ANALYZE, BUFFERS)`. |55| `EXPLAIN (SERIALIZE, MEMORY)` | PG 17+. |56| `EXPLAIN (GENERIC_PLAN)` | PG 16+. |57| B-tree skip scan (leading column can be omitted) | PG 18+. On older majors a composite index is unusable without its leading column. |5859When you are unsure whether a feature exists on the server in front of you, ask the60server rather than guessing — `\d pg_stat_io`, `SELECT ... FROM pg_extension`, or read61the manual for that exact major.6263### 1. Find where the time actually goes6465Run the bundled read-only diagnostic instead of hand-rolling the queries — it adapts its66SQL to the server version and covers the whole picture in one pass:6768```bash69<skill-dir>/scripts/pg-diagnose.sh "$DATABASE_URL"70```7172`<skill-dir>` is the base directory printed when this skill loads — the script lives73next to this file, not in the user's project. Options go before the connection string74(`-n 30` for more rows per section, `-q` to skip the pg_stat_statements sections).7576It reports: top statements by total and by mean time, cache hit ratios, tables taking77sequential scans, unused and duplicate and invalid indexes, dead-tuple and autovacuum lag,78bloat estimates, and current blocking locks. Read79[references/diagnose-postgres.md](references/diagnose-postgres.md) when you need to go80past what the script prints, or to interpret what it printed.8182Rank by **total time**, not by mean. A 5 ms query called two million times an hour costs83more than a 3-second report run once. Both matter, but they get different fixes.8485### 2. Get the real plan8687```sql88EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, FORMAT TEXT) <query>;89```9091`ANALYZE` executes the statement. Wrap writes in a transaction you roll back, and never92run it against production on a statement whose side effects you have not read.9394Read the plan bottom-up and look at three things per node: the ratio between estimated95and actual rows, the buffers, and `loops`. `actual time` is per loop — multiply it out96before you believe a node is cheap. [references/explain-plans.md](references/explain-plans.md)97has the node-by-node reading guide and the red flags.9899### 3. Classify before you fix100101Match the symptom to the cause. Most slow queries are one of these:102103| Symptom in the plan | Likely cause | Where to go |104| --- | --- | --- |105| Seq Scan on a large table with a selective filter | missing or unusable index | [index-design](references/index-design.md) |106| Index Scan present but still slow, high heap buffers | index not covering, or high correlation loss | [index-design](references/index-design.md) |107| Estimated rows off by 10x or more | stale or insufficient statistics, correlated columns | [maintenance](references/maintenance.md) |108| Filter discards most rows (`Rows Removed by Filter`) | predicate not sargable, or wrong column order | [query-rewrites](references/query-rewrites.md) |109| Nested Loop with a huge `loops` count | bad row estimate upstream, or an N+1 from the app | [query-rewrites](references/query-rewrites.md) |110| Sort or Hash spilling to disk (`Sort Method: external merge`) | `work_mem` too low for this shape | [maintenance](references/maintenance.md) |111| Same query fast on a fresh copy, slow in prod | bloat, or a plan flip from parameter sniffing | [maintenance](references/maintenance.md) |112| Query waits, plan itself is fine | lock contention | [diagnose-postgres](references/diagnose-postgres.md) |113114Say out loud which one you picked and what evidence picked it. If two fit, you have not115measured enough yet.116117### 4. Change one thing118119One index, or one rewrite, or one setting — never a batch. A batch that improves things120teaches you nothing about which part did it, and a batch that regresses is hard to unwind.121122Every index change on a live system goes through123[references/apply-safely.md](references/apply-safely.md): `CREATE INDEX CONCURRENTLY`,124a `lock_timeout`, a rollback path, and the `prod-api-safety` protocol before the first125write. `CREATE INDEX CONCURRENTLY` cannot run inside a transaction block, does two table126scans, and leaves an `INVALID` index behind if it fails — that reference covers the127cleanup.128129Before adding an index, check that an existing one cannot be extended instead. Two130indexes on `(a)` and `(a, b)` mean the first is usually dead weight.131132### 5. Verify with the same number you started with133134Re-run the exact measurement from step 1 and put the before and after side by side:135136```137tenant dashboard query138 before: 1,240 ms mean, 38,400 shared blocks read, Seq Scan on orders139 after: 12 ms mean, 41 shared blocks read, Index Scan using orders_tenant_created_idx140 index: orders (tenant_id, created_at DESC) — 240 MB, built CONCURRENTLY in 4m12s141```142143Report writes too, not just the read you fixed: an index that made a report 100x faster144and inserts 20% slower is a trade, and the person paying for it should see both sides.145`SELECT pg_stat_statements_reset()` before an after-measurement, and note that you did.146147Never report an improvement you have not re-measured on the same workload. A faster148`EXPLAIN ANALYZE` on a warm cache is not a production win.149150## Standing rules151152- **Read-only until proven otherwise.** Diagnosis never needs write access. Connect with a153 read-only role, or `SET default_transaction_read_only = on`, and keep it that way until154 you have a specific change to apply.155- **The database may not be the problem.** Check whether the time is in the query, the156 round trips, the connection pool, or serialization to the client. `EXPLAIN (SERIALIZE)`157 on PG 17+ tells you how much is output conversion.158- **Never `SET` a global on a live server to test a hypothesis.** Use a session-level159 `SET` in your own connection, or `EXPLAIN` with the setting changed locally.160- **Don't trust a plan from a different data distribution.** Staging with 10k rows will161 happily choose a plan production would never pick.162- **`ANALYZE` after any bulk load or big migration**, before you judge anything.163164## References165166Read the one that matches what you are doing. They are written to be opened mid-task.167168| File | Read it when |169| --- | --- |170| [references/diagnose-postgres.md](references/diagnose-postgres.md) | Collecting evidence: pg_stat_statements, table and index stats, waits, locks, connections, bloat. |171| [references/explain-plans.md](references/explain-plans.md) | Reading an EXPLAIN output node by node and spotting the red flags. |172| [references/index-design.md](references/index-design.md) | Choosing the index: column order, partial, covering, expression, GIN/GiST/BRIN/hash, and when not to. |173| [references/query-rewrites.md](references/query-rewrites.md) | The query itself is the problem: sargability, N+1, pagination, OR, EXISTS, LATERAL, CTEs, window functions. |174| [references/maintenance.md](references/maintenance.md) | Vacuum, autovacuum tuning, bloat, statistics targets, extended statistics, partitioning, fillfactor, reindex. |175| [references/apply-safely.md](references/apply-safely.md) | Applying a change to a live database without taking an outage. |176| [references/mysql.md](references/mysql.md) | The server is MySQL or MariaDB — InnoDB specifics, EXPLAIN ANALYZE, sys schema, index dives. |