Databases No. 210Editor's pick

SQL Query Tuning skill for Claude Code

Find the slow plan node, fix the estimate behind it. Claude stops guessing from SQL text and works from the plan. It ranks queries by total time in pg_stat_statements, captures EXPLAIN ANALYZE with real parameters, finds the node where estimates diverge, then applies the smallest fix in order: statistics, query rewrite, index, scoped config. Every change gets a before/after plan comparison with buffer counts and a short tuning note.

  • namesql-query-tuning
  • size215 lines · ≈2,550 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Diagnoses and fixes slow SQL queries by reading EXPLAIN ANALYZE plans, finding the top offenders in pg_stat_statements, correcting bad row estimates with ANALYZE and extended statistics, and rewriting queries (sargable predicates, keyset pagination, EXISTS vs IN, join order). Use when a query or endpoint is slow, a plan shows a Seq Scan, Nested Loop, or disk sort, database CPU is high, or a query got slower after data growth or an upgrade. PostgreSQL first, with MySQL notes.

Try asking

  • This dashboard query takes 6 seconds on prod, here's the EXPLAIN ANALYZE output
  • Database CPU is pegged at 90% - find which queries are responsible
  • Our paginated list endpoint gets slower the further you page. Fix it

Use it when

  • A specific query or endpoint is slow and you can run EXPLAIN
  • Database CPU or I/O is high and you need to know which statements cause it
  • A query regressed after data growth, a version upgrade, or a deploy
  • The planner ignores an index you expected it to use

Skip it when

How to check the result

Re-run EXPLAIN (ANALYZE, BUFFERS) with the same parameters and confirm the target node changed, total buffers dropped, and other top queries kept their plans.

The common mistake it prevents

Benchmarking on a small dev dataset produces plans the planner would never choose at production scale, so the fix looks great locally and does nothing in prod.

~/.claude/skills/sql-query-tuning/SKILL.md

--- name: sql-query-tuning description: Diagnoses and fixes slow SQL queries by reading EXPLAIN ANALYZE plans, finding the top offenders in pg_stat_statements, correcting bad row… license: MIT ---

A query is tuned when you can name the plan node that was slow, explain why the planner chose it, show a before/after plan on production-sized data, and the fix does not just move the cost to writes or another query. Never tune from the SQL text alone; tune from the plan.

Ground rules

  • Measure on data shaped like production. A plan on 1,000 rows tells you nothing about a plan on 50M rows; the planner picks different strategies by size.
  • EXPLAIN ANALYZE executes the statement. Wrap writes: BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK; and never run it on production writes without the user’s explicit go-ahead.
  • Change one thing at a time and re-capture the plan after each change.
  • Fix estimates before forcing plans. Most bad plans are bad row estimates.

Workflow

- [ ] 1. Find which queries matter (total time, not just slowest single run)
- [ ] 2. Capture the real plan with parameters production uses
- [ ] 3. Locate the expensive node and the estimate error
- [ ] 4. Classify the cause (table below)
- [ ] 5. Apply the smallest fix: statistics -> rewrite -> index -> config
- [ ] 6. Re-capture plan, compare, check write/other-query impact
- [ ] 7. Write the tuning note

1. Find the queries that matter

-- Requires pg_stat_statements in shared_preload_libraries + CREATE EXTENSION
SELECT queryid,
       calls,
       round(total_exec_time::numeric, 0)         AS total_ms,
       round(mean_exec_time::numeric, 1)          AS mean_ms,
       round((100 * total_exec_time / sum(total_exec_time) OVER ())::numeric, 1) AS pct,
       rows / greatest(calls, 1)                  AS rows_per_call,
       shared_blks_read + shared_blks_hit         AS blks,
       left(query, 120)                           AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

A 5 ms query called 2M times a day usually matters more than a 4 s report run once. Rank by total_exec_time; look at mean only to decide the tuning approach.

To catch slow plans in the act, enable auto_explain (session or config):

LOAD 'auto_explain';
SET auto_explain.log_min_duration = '500ms';
SET auto_explain.log_analyze = on;
SET auto_explain.log_buffers = on;

2. Capture the real plan

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE) <query>;
-- PG18 includes BUFFERS by default with ANALYZE
-- JSON for tools: EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) ...
  • Use the literal parameter values from the slow case. Skewed values (“tenant 1 has 40% of rows”) produce different plans.
  • Prepared statements may switch to a generic plan after five executions. If the app uses them (most drivers and ORMs do), compare with SET plan_cache_mode = force_generic_plan; then PREPARE/EXPLAIN EXECUTE.
  • Run it twice; the first run may be cold cache. Report both if they differ greatly.
  • Paste large plans into a visualizer (explain.dalibo.com, explain.depesz.com) only if the plan contains no sensitive literals or the user agrees.

3. Read the plan

Read inside-out, and for each node compare:

  • rows= estimated vs actual rows= multiplied by loops=. An error of 10x or more is the usual root cause.
  • actual time= is per loop: total node time is roughly time * loops.
  • Buffers: shared hit= (cache) vs read= (disk). Many reads mean I/O-bound; consider whether the working set fits shared_buffers.
  • Rows Removed by Filter large relative to rows returned: the index (if any) is not selective for this predicate.
  • Sort Method: external merge Disk: ... or Hash ... Batches: >1: work_mem too small for that operation, or too many rows reach that node.

For node-by-node interpretation and what each warning sign means, read references/explain-nodes.md.

4. Classify the cause

Symptom in planLikely causeFirst fix
Estimate off 10x+ on a single-table filterStale or coarse statsANALYZE tbl; raise column statistics target
Estimate off on combined predicates (city + zip)Correlated columns assumed independentCREATE STATISTICS ... (dependencies, mcv)
Seq Scan returning a small fraction of a big tableMissing or unusable indexIndex, or make predicate sargable
Index exists but unusedFunction/cast on column, leading column not filtered, low selectivityRewrite predicate or expression index
Nested Loop with huge loops on inner sideUnderestimated outer rowsFix estimate; then hash join wins naturally
Sort spilling to diskSorting many rows / low work_memIndex matching ORDER BY + LIMIT, or raise work_mem for that role/session
OFFSET 100000 slowOffset reads and discards rowsKeyset pagination
Time grows with count(DISTINCT) or DISTINCT after joinsJoin fan-out then dedupeEXISTS / pre-aggregate before joining
Fast in psql, slow in appGeneric plan, different params, or N+1 in appTest generic plan; check call counts
Many short queries per requestN+1 from ORMSee n-plus-one-queries

5. Fix, smallest first

Statistics

ANALYZE orders;                                         -- refresh now
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;  -- finer histogram (default 100)
ANALYZE orders;

CREATE STATISTICS orders_city_zip_stats (dependencies, ndistinct, mcv)
  ON city, zip FROM orders;
ANALYZE orders;

Check freshness: SELECT relname, last_analyze, last_autoanalyze, n_mod_since_analyze FROM pg_stat_user_tables ORDER BY n_mod_since_analyze DESC;

Rewrites that change the plan

Instead ofWrite
WHERE date(created_at) = '2026-01-05'WHERE created_at >= '2026-01-05' AND created_at < '2026-01-06'
WHERE lower(email) = $1 without matching indexexpression index ON users (lower(email)), or citext
WHERE id::text = $1 / mismatched param typesCast the parameter, not the column
WHERE a = $1 OR b = $2 (no plan uses both indexes well)UNION ALL of two indexed queries (dedupe if needed)
NOT IN (SELECT col ...)NOT EXISTS (SELECT 1 ... WHERE ...) (also NULL-safe)
IN (SELECT ...) joined then DISTINCTEXISTS semi-join
ORDER BY created_at LIMIT 20 OFFSET 50000WHERE (created_at, id) < ($last_ts, $last_id) ORDER BY created_at DESC, id DESC LIMIT 20
Correlated scalar subquery per row in SELECTLEFT JOIN LATERAL (...) ON true or pre-aggregated join
SELECT * when an index-only scan is possibleSelect only needed columns; consider INCLUDE columns
count(*) for a UI badge on a huge tableEstimate from pg_class.reltuples or cap: SELECT count(*) FROM (SELECT 1 ... LIMIT 1001) s

CTEs: since PG12 non-recursive CTEs referenced once are inlined. Use WITH x AS MATERIALIZED (...) to force an optimization fence, NOT MATERIALIZED to force inlining.

Indexes: if a new index is the fix, design it with postgres-indexing (column order, partial, covering) and build it CONCURRENTLY.

Configuration (last, scoped narrowly):

SET work_mem = '64MB';              -- per sort/hash node, per query; not global
ALTER ROLE reporting SET work_mem = '256MB';
SET jit = off;                      -- if JIT compile time dominates short OLTP queries

Never raise global work_mem blindly: it is per operation, and a query can use several multiples of it across many connections.

Avoid planner switches (enable_nestloop = off) except as a diagnostic to prove a different plan is faster. Do not ship them.

6. Verify

  1. Re-run EXPLAIN (ANALYZE, BUFFERS) with the same parameters; the target node changed and total time dropped. Note buffers too: fewer buffers is the more stable signal than wall time.
  2. Test the other parameter extremes (largest tenant, empty result, newest data).
  3. If you added an index or statistics, check write-heavy paths are not slower and that no other top query in pg_stat_statements changed plan for the worse.
  4. After deploy, reset and watch: SELECT pg_stat_statements_reset(); only with user consent (it clears shared history), or compare mean_exec_time for the queryid.

MySQL notes (8.0+)

  • EXPLAIN ANALYZE <select> (8.0.18+) gives actual timings in tree form; EXPLAIN FORMAT=TREE shows the plan without executing.
  • Slow query log: slow_query_log=ON, long_query_time=0.5; aggregate with pt-query-digest. performance_schema.events_statements_summary_by_digest is the pg_stat_statements equivalent.
  • type: ALL = full scan; Using filesort / Using temporary in Extra flag sorts and temp tables. Refresh stats with ANALYZE TABLE t; and add histograms with ANALYZE TABLE t UPDATE HISTOGRAM ON col WITH 64 BUCKETS;.
  • InnoDB secondary indexes contain the PK, so the PK is implicitly “included”.

Gotchas

  • LIMIT with ORDER BY on a non-indexed column still sorts every matching row.
  • A plan that is fast because of a warm cache will be slow after a failover or restart. Look at read= buffers, not only time.
  • Implicit casts kill index use: comparing varchar to a numeric parameter, or timestamptz column to a date expression evaluated per row.
  • ILIKE '%term%' cannot use a B-tree; needs pg_trgm GIN or full-text search.
  • Huge IN (...) lists (thousands of literals) bloat planning time; use = ANY($1::bigint[]) or join against unnest($1).
  • Autovacuum falling behind causes bloated tables and stale visibility maps, which make index-only scans fetch the heap (Heap Fetches high). Check vacuum before blaming the query.

Output: tuning note

**Query:** GET /api/orders list (queryid 81726354), 31% of total DB time.
**Before:** 1,840 ms mean; Seq Scan on orders (12.4M rows), Rows Removed by Filter 12.39M,
estimate 1,200 rows vs actual 48.
**Cause:** `WHERE date(placed_at) = $1` is not sargable; no usable index.
**Fix:** Rewrote to a half-open range; added `orders (tenant_id, placed_at DESC)`
built CONCURRENTLY.
**After:** 3.2 ms mean; Index Scan, 52 buffers vs 410k.
**Side effects checked:** insert latency unchanged (p95 1.1 ms); no other top-20 plan changed.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/explain-nodes.md 111 lines · 6.1 KB
# Reading PostgreSQL plan nodes

Read this when a plan contains a node you need to interpret, or when the classification
table in SKILL.md does not obviously match.

## Anatomy of a node line

```
Index Scan using orders_tenant_placed_idx on orders  (cost=0.56..812.40 rows=210 width=96) (actual time=0.031..1.904 rows=48 loops=3)
  Index Cond: ((tenant_id = 42) AND (placed_at >= '2026-01-01'::timestamptz))
  Filter: (status <> 'cancelled'::text)
  Rows Removed by Filter: 5
  Buffers: shared hit=61 read=4
```

- `cost=startup..total` is in arbitrary planner units; only compare costs within one plan.
- `rows=210` is the estimate per loop; `actual rows=48` per loop; `loops=3` means the node
  ran three times (total rows = 144).
- `actual time=first_row..last_row` in ms, per loop.
- `Index Cond` is applied inside the index; `Filter` is applied after fetching the row.
  Conditions that appear in `Filter` instead of `Index Cond` are not using the index.
- `width` is average row bytes; wide rows make sorts and hashes spill sooner.

## Scan nodes

| Node | Meaning | Worry when |
|---|---|---|
| Seq Scan | Reads the whole table | Table is large and the node returns a small fraction of it |
| Index Scan | Walks index, fetches each heap row | Many rows returned with random heap access (bitmap may be better) |
| Index Only Scan | Answers from the index alone | `Heap Fetches` is high: visibility map stale, vacuum needed |
| Bitmap Index Scan + Bitmap Heap Scan | Collects matching TIDs, reads heap in physical order | `Recheck Cond` with `lossy` blocks: `work_mem` too small for the bitmap; `Rows Removed by Index Recheck` large |
| BitmapAnd / BitmapOr | Combines several indexes | Frequent on hot queries: a single composite index is usually faster |
| Tid Scan / Tid Range Scan | Direct by ctid | Rarely relevant |
| Function Scan | Set-returning function | Estimates default to 1000 rows unless the function declares `ROWS` |
| CTE Scan | Reads a materialized CTE | CTE forced materialized and filtered afterwards; consider `NOT MATERIALIZED` |
| Subquery Scan | Wrapper over a subquery | Usually harmless |

PG18 B-tree skip scan: a multicolumn index can now be used when the leading column is not
constrained but has few distinct values. Look for `Index Searches:` in PG18 output to see
how many descents were made.

## Join nodes

| Node | Good for | Pathology |
|---|---|---|
| Nested Loop | Small outer side, indexed inner lookups | Outer side underestimated: inner index probe runs 100k+ times (`loops=`) |
| Hash Join | Large unsorted inputs, equality joins | `Batches: N` > 1 or memory exceeds `work_mem * hash_mem_multiplier`: spilling to disk |
| Merge Join | Both inputs already sorted (by index) | Explicit Sort nodes feeding it on big inputs |
| Memoize (PG14+) | Caches inner results for repeated keys in a nested loop | `Evictions` high / `Hits` low: cache too small or keys unique |

Join order matters most when estimates are wrong. Fix the estimate on the outer input
first; the planner will usually pick the right join type afterwards.

`join_collapse_limit` / `from_collapse_limit` (default 8): queries with more joined
relations than this are not fully reordered. For 10+ table joins, write the most
selective joins first or raise the limit for that session.

## Aggregation and sorting

| Node | Notes |
|---|---|
| Sort | `Sort Method: quicksort Memory:` fine; `external merge Disk:` spilled |
| Incremental Sort (PG13+) | Input already sorted by a prefix; cheap; good sign |
| Top-N heapsort | `ORDER BY ... LIMIT n` without a matching index; still reads all input rows |
| HashAggregate | `Batches` > 1 or `Disk Usage` means spill (PG13+ can spill instead of OOM) |
| GroupAggregate | Requires sorted input; often follows an index or Sort |
| Unique | DISTINCT over sorted input |
| WindowAgg | Sorting for PARTITION BY/ORDER BY often dominates; an index on the partition+order columns helps |
| Limit | If its child shows far more actual rows than the limit, something below cannot stop early (a sort) |

## Parallelism

- `Gather` / `Gather Merge` with `Workers Planned: 2  Workers Launched: 0` means no
  workers were available (`max_parallel_workers` exhausted). The plan was costed assuming
  parallelism and may run much slower.
- Per-worker numbers appear under `Worker N:`; `actual rows` on parallel nodes is per
  process, so multiply by `loops`.

## Modify nodes

- `Insert`, `Update`, `Delete`, `Merge` show time spent writing. Trigger time is listed
  separately at the end (`Trigger for constraint ...: time=... calls=...`). A slow
  `DELETE` with a long `RI_ConstraintTrigger` line means a referencing table has no index
  on its FK column.

## Bottom-of-plan lines

- `Planning Time` large (tens of ms) for simple queries: huge IN lists, many partitions
  without pruning, or very many indexes.
- `JIT:` section with `Timing: ... Total` large relative to execution: disable JIT for
  that workload (`jit = off`) or raise `jit_above_cost`.
- `Execution Time` excludes network transfer and client rendering. If the app is slower
  than this number, the problem is outside the query (rows transferred, N+1, pool waits).

## Estimate errors: where they come from

| Pattern | Why the planner is wrong | Fix |
|---|---|---|
| Fresh bulk load | No stats yet | `ANALYZE` after loads |
| Skewed values | Default histogram too coarse | Raise statistics target on that column |
| Correlated columns | Independence assumption multiplies selectivities | `CREATE STATISTICS (dependencies, mcv)` |
| Expression in WHERE | No stats on the expression | Expression index (gets its own stats) or `CREATE STATISTICS` on the expression (PG14+) |
| Joins on non-unique keys | Fan-out underestimated | Extended stats rarely help; restructure (pre-aggregate) |
| Table-valued function | Fixed default estimate | Declare `ROWS n` on the function |
| Partitioned tables | Pruning happens at execution time for params | Check `Subplans Removed:` lines |

## Comparing two plans

Report for both: execution time, total shared buffers (hit + read), the top three nodes
by exclusive time, and any spills. Faster wall time with more buffers is often cache
luck; fewer buffers is the durable improvement.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc