SQL and querying skill 05 of 8

Query optimisation

The Query Optimisation skill makes Claude, Codex or another AI agent find why a SQL query is slow before changing anything. It reads the EXPLAIN plan, spots the operator that dominates the time, and fixes the real bottleneck with a rewrite, fresh statistics or the right index, so the query speeds up for a reason you can point to rather than a guess.

query-optimization/SKILL.md, version 1.0.0, about 2,700 words.

Download .mdSkill folder (.zip)

When to use this, and when not to

Use this skill to find out why a SQL query is slow and fix the one thing that is actually slow, by reading its plan, spotting the costly operator, and rewriting the query, refreshing statistics or routing to an index. The triggers are a slow query, a query that times out, high database CPU, a plan you need to read, or a full table scan you did not expect.

Do not use it to design the column order of an index once you have confirmed an index is the fix: run database-indexing for that. Do not use it for keyset pagination design (efficient-pagination), join result logic (sql-joins) or window frames (window-functions). The defining feature of this skill is diagnosis: find the dominant bottleneck first, then apply the smallest change that moves it.

The common mistake this skill guards against is optimising before diagnosing: adding an index, a cache or a rewrite to a query whose real problem is somewhere else. Read the plan first.

Inputs the agent must have before starting

  1. The SQL engine and version. Plan output and the fixes differ (see the appendix).
  2. The exact query, with a representative parameter value, not a toy one. A query that is fast on a developer's 1,000 rows can fail at production cardinality.
  3. The table sizes in rows, how often the query runs, and the concurrent load.
  4. The current indexes on the tables involved.
  5. Whether you can run EXPLAIN ANALYZE safely: this is production or a copy, and the statement is a SELECT or a data-modifying statement.
  6. The latency target, so you know when to stop.

If you cannot run the plan yourself, ask for the EXPLAIN (and ideally EXPLAIN ANALYZE) output. Diagnosing from the query text alone is a guess. If the statement writes data, do not run EXPLAIN ANALYZE until you have read the safety section.

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as query-optimization/SKILL.md.

---
name: query-optimization
description: >
  Diagnose and fix slow SQL queries by reading the execution plan and fixing the dominant bottleneck, across PostgreSQL, MySQL and MariaDB, SQLite and SQL Server: EXPLAIN and EXPLAIN ANALYZE, estimated-versus-actual rows, scan and join choices, sargable rewrites, N+1 and over-fetching. Use it when someone says "this query is slow", "speed up this SQL", "why is this query slow", "the query times out", "database CPU is high", "read this EXPLAIN", "what does this query plan mean", "it is doing a full table scan", or pastes a plan and asks what to do. Do NOT use it to design the index column order once an index is clearly the fix (see database-indexing), for keyset pagination design (see efficient-pagination), for join result logic (see sql-joins), or for window frame tuning (see window-functions).
license: Free to use and adapt under CC BY 4.0, with credit to Afeez Adebayo. Link afeezadebayo.com, https://www.linkedin.com/in/afeez-adebayo/ and this skill's page at https://afeezadebayo.com/skills-md/data/sql-querying/query-optimization/.
---

# Query optimisation

## When to use this, and when not to

Use this skill to find out why a SQL query is slow and fix the one thing that is actually slow, by reading its plan, spotting the costly operator, and rewriting the query, refreshing statistics or routing to an index. The triggers are a slow query, a query that times out, high database CPU, a plan you need to read, or a full table scan you did not expect.

Do not use it to design the column order of an index once you have confirmed an index is the fix: run `database-indexing` for that. Do not use it for keyset pagination design (`efficient-pagination`), join result logic (`sql-joins`) or window frames (`window-functions`). The defining feature of this skill is diagnosis: find the dominant bottleneck first, then apply the smallest change that moves it.

The common mistake this skill guards against is optimising before diagnosing: adding an index, a cache or a rewrite to a query whose real problem is somewhere else. Read the plan first.

## Inputs the agent must have before starting

1. The SQL engine and version. Plan output and the fixes differ (see the appendix).
2. The exact query, with a representative parameter value, not a toy one. A query that is fast on a developer's 1,000 rows can fail at production cardinality.
3. The table sizes in rows, how often the query runs, and the concurrent load.
4. The current indexes on the tables involved.
5. Whether you can run `EXPLAIN ANALYZE` safely: this is production or a copy, and the statement is a `SELECT` or a data-modifying statement.
6. The latency target, so you know when to stop.

If you cannot run the plan yourself, ask for the `EXPLAIN` (and ideally `EXPLAIN ANALYZE`) output. Diagnosing from the query text alone is a guess. If the statement writes data, do not run `EXPLAIN ANALYZE` until you have read the safety section.

## The workflow, step by step

### 1. Reproduce and baseline

Capture the exact SQL and a representative parameter, run it once, and record the time and the number of rows returned. This is the number every later change is measured against. Do not optimise a query you have not timed.

### 2. Classify the bottleneck before changing anything

Decide which layer is slow, because the fix differs:

- High database execution time: a plan, index, data or statistics problem. Continue below.
- Low database time but high end-to-end latency: the application, serialisation or network, not the SQL.
- Many queries per request: an N+1 access pattern (step 7), not one slow query.
- A fast query that transfers a huge amount of data: a projection or pagination problem (step 7).
- Requests waiting on a connection: a pool or concurrency problem, not the query.

Fix the dominant layer. Tuning the SQL will not help a request that is really waiting on a connection pool.

### 3. Get the plan

Run `EXPLAIN` to see the planner's chosen plan and its estimates. Then run `EXPLAIN ANALYZE` to get the actual row counts and run times, but only when it is safe: it executes the query. On PostgreSQL add `BUFFERS`. For an `INSERT`, `UPDATE`, `DELETE` or `MERGE`, wrap it so it does not change data:

```sql
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE ...;
ROLLBACK;
```

### 4. Read the plan (the signals that matter)

The single most useful signal is the gap between estimated rows and actual rows. If the estimate is off by roughly ten times or more, the planner is working from bad information and will often pick the wrong plan; fix that first (step 5). Then read the operators:

- PostgreSQL: each node shows `cost=startup..total` in arbitrary units (not milliseconds), and under `ANALYZE`, `actual time` in milliseconds and `loops`. The per-node `actual time` and `rows` are per loop, so multiply by `loops` for the total. Watch for a `Seq Scan` on a large table with a selective filter and a high `Rows Removed by Filter`, or a `Sort` that dominates the time.
- MySQL: the `type` column ranks the access method. `ALL` is a full table scan and is the usual thing to fix on a large table; `ref`, `range`, `eq_ref` and `const` are progressively better. Read `key` (the index used, `NULL` if none), `rows` (estimated rows examined), and `Extra`: `Using filesort` means a sort with no index, `Using temporary` means a temp table (often from `GROUP BY` or `DISTINCT`), `Using index` means a covering index.
- SQLite: `EXPLAIN QUERY PLAN` shows `SCAN` for a full scan and `SEARCH ... USING INDEX` when an index narrows it; `USE TEMP B-TREE FOR ORDER BY` means the sort was not served by an index.

### 5. Fix cardinality errors before rewriting

If estimated and actual rows diverge badly, refresh the statistics with `ANALYZE` (PostgreSQL and SQLite) or `ANALYZE TABLE` (MySQL) and re-read the plan. For columns that are correlated (city and country, say), single-column statistics mislead the planner; on PostgreSQL create extended statistics. Do not treat "query slow, run `ANALYZE`" as a reflex: confirm the estimate is actually wrong before you act on it.

### 6. Make the predicates sargable

A predicate that wraps the column in a function, a cast or arithmetic, or that starts a `LIKE` with a wildcard, stops the engine using an index and forces a scan. Rewrite so the column stands bare on one side:

- `WHERE EXTRACT(YEAR FROM created_at) = 2026` becomes `WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'`.
- `WHERE date_trunc('day', created_at) = '2026-07-16'` becomes a half-open range on `created_at` the same way.
- `WHERE price * 1.2 > :limit` becomes `WHERE price > :limit / 1.2`.
- `WHERE CAST(id AS text) LIKE :p` and `WHERE phone::bigint = :n`: cast the constant, not the column.
- `WHERE lower(email) = :e`: store the value already normalised, add an expression index on `lower(email)`, or use a case-insensitive type or collation.
- `WHERE name LIKE '%phones'` (leading wildcard): a plain B-tree cannot help; use a trigram index, and note it changes which rows match.

These matter once the table is large. On a small table the engine scans anyway, and that is correct.

### 7. Cut the work the query does

- Select only the columns you need. `SELECT *` carries large text and `jsonb` columns the application never uses and inflates transfer; name the columns.
- Bound the result with a `LIMIT`. An unbounded production query is a latent outage.
- Remove an N+1 pattern (one query, then one more per row) by batching or joining, but do not replace it with a join that multiplies rows and transfers far more than the screen needs.
- Filter as early as possible, before joins and aggregation, so fewer rows flow through the plan.

### 8. Fix the dominant operator

Change the one node that dominates the time, then re-measure:

- A `Sort` that dominates, under an `ORDER BY`: an index that matches the ordering lets the engine skip the sort. Hand the column order to `database-indexing`.
- A nested loop with a huge `loops` count and an unindexed inner side: index the inner join key, or let the planner choose a hash or merge join by giving it good statistics. Do not force a join type before you understand why it chose this one.
- A `Seq Scan` on a large table with a selective predicate: an index is likely the fix. But a `Seq Scan` that returns most of the table is correct, so do not force an index onto it.

### 9. Decide whether an index is really the fix

An index is one fix among several, and often not the first. A rewrite that removes work, a projection that cuts transfer, or fresh statistics can beat a new index and help more queries. Once you have confirmed the bottleneck really is index access, hand the design to `database-indexing` for the column order, and weigh the write cost there.

### 10. Re-measure and verify

Re-run `EXPLAIN ANALYZE` and confirm the plan actually changed and the time dropped against the baseline. Test a second, different parameter value, because a plan that is fast for one distribution can be slow for another. Confirm the results are identical to before unless you changed behaviour on purpose, and check the write impact of any index you added.

## Worked example, end to end

A dashboard endpoint runs this on a 20 million row `events` table and takes about 900 ms:

```sql
SELECT *
FROM events
WHERE account_id = :acct
  AND date(created_at) = :day
ORDER BY created_at DESC;
```

`EXPLAIN (ANALYZE, BUFFERS)` shows a sequential scan that reads the whole table, discards almost all of it, then sorts:

```
Sort  (actual time=905.2..905.4 rows=180)
  Sort Key: created_at DESC
  ->  Seq Scan on events  (actual time=0.1..880.6 rows=180 loops=1)
        Filter: (account_id = :acct AND date(created_at) = :day)
        Rows Removed by Filter: 19999820
```

Three problems. The `date(created_at)` wraps the column, so no index on `created_at` can be used (step 6). `SELECT *` pulls a large `payload` `jsonb` column the dashboard does not show (step 7). And there is no index for this access path (step 8).

Fix the query first: make the date filter a half-open range and select only the needed columns.

```sql
SELECT id, account_id, created_at, event_type
FROM events
WHERE account_id = :acct
  AND created_at >= :day
  AND created_at <  :day + INTERVAL '1 day'
ORDER BY created_at DESC;
```

Then, having confirmed the bottleneck is index access, add the index (column order from `database-indexing`): equality column first, then the range and sort column.

```sql
CREATE INDEX idx_events_acct_created
  ON events (account_id, created_at DESC);
```

The plan is now an index scan that reads only this account's rows for the day, already in order, with no sort, and runs in a few milliseconds:

```
Index Scan using idx_events_acct_created on events  (actual time=0.05..0.6 rows=180 loops=1)
  Index Cond: (account_id = :acct AND created_at >= :day AND created_at < :day + '1 day')
```

Re-measured against the 900 ms baseline, verified with a second account, results identical.

## Templates you can use verbatim

```sql
-- PostgreSQL: estimate, then actual with I/O
EXPLAIN SELECT ...;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
-- Safe analysis of a data-modifying statement
BEGIN; EXPLAIN (ANALYZE, BUFFERS) UPDATE ...; ROLLBACK;
-- Refresh statistics when estimates are wrong
ANALYZE my_table;                 -- PostgreSQL / SQLite
ANALYZE TABLE my_table;           -- MySQL

-- MySQL: plan, actual timings (8.0.18+), and JSON detail
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...;
EXPLAIN FORMAT=JSON SELECT ...;

-- SQLite: which indexes the plan uses
EXPLAIN QUERY PLAN SELECT ...;

-- Sargable rewrites (keep the column bare on one side)
-- year filter:   EXTRACT(YEAR FROM d) = 2026  ->  d >= '2026-01-01' AND d < '2027-01-01'
-- day filter:    date(d) = :day               ->  d >= :day AND d < :day + 1 day
-- arithmetic:    price * 1.2 > :lim           ->  price > :lim / 1.2
-- cast:          CAST(col AS text) = :s        ->  col = CAST(:s AS col_type)
-- case-insensitive: lower(email) = :e          ->  expression index on lower(email), or citext
```

## Failure modes and anti-patterns

1. **Optimising before reading the plan.** Spot it when someone adds an index, a cache or a rewrite with no `EXPLAIN` to justify it. Fix: get the plan first and let the dominant operator and the estimated-versus-actual gap tell you what to change.
2. **Treating a sequential scan as a bug.** Spot it when an index is forced onto a query that returns most of the table. Fix: a scan that reads most rows is cheaper than an index and is the right plan; only index a selective predicate on a large table.
3. **Reflex fixes: "slow, so run ANALYZE" or "slow, so add an index".** Spot it when the same move is applied to every slow query. Fix: confirm the estimate is actually wrong before `ANALYZE`, and confirm the bottleneck is index access before adding an index; a rewrite often helps more and more broadly.
4. **Running EXPLAIN ANALYZE on a mutating statement without protection.** Spot it when `EXPLAIN ANALYZE` is run on an `UPDATE` or `DELETE` on production: it executes and changes data. Fix: wrap it in `BEGIN; ... ROLLBACK;`, or analyse a read-only equivalent, and get confirmation before running heavy analysis on production.
5. **Optimising for one lucky parameter.** Spot it when a query is tuned with one value (a tiny account, an empty tenant) and regresses for the common case. Fix: test representative and skewed parameter values, because the best plan can differ by distribution.

## Quality bar

- The query was timed before and after, against the same representative parameter.
- The bottleneck layer was identified (database, application, transfer or connection) before any change.
- The plan was read, and the estimated-versus-actual row gap was checked and addressed if large.
- Any predicate that blocked an index was made sargable, and `SELECT *` was narrowed where it carried unused columns.
- The dominant operator was the thing changed, and the change was confirmed in a re-run plan with a lower time.
- A second parameter distribution was tested, results are unchanged, and the write impact of any new index was weighed.
- `EXPLAIN ANALYZE` on a data-modifying statement was wrapped in a transaction and rolled back, and heavy or production analysis was confirmed with a human.

## Data handling and safety

`EXPLAIN ANALYZE` executes the query, so running it on an `INSERT`, `UPDATE`, `DELETE` or `MERGE` changes data: wrap it in `BEGIN; ... ROLLBACK;` or analyse a read-only equivalent, and get explicit human confirmation before running heavy or mutating analysis against a shared or production database. Adding or dropping an index, if you reach that step, is a schema change: hand it to `database-indexing`, which treats the DDL as an irreversible action needing confirmation. Optimisation uses the query text, plan output and row-count estimates, not the data values, so refer to columns by name and never paste real personal, health or financial values into prompts or shared plan logs; replace literals with placeholders when sharing a query. Do not run optimisation experiments against production without a go-ahead.

## Appendix: engine-specific notes (verify before relying on these)

- **PostgreSQL**: costs are in arbitrary units, not time; `actual time` is in milliseconds and per loop, so multiply by `loops`. Use `EXPLAIN (ANALYZE, BUFFERS)` for I/O. `pg_stat_statements` finds the expensive queries across the workload, and `auto_explain` logs slow plans. Under `LIMIT`, estimated rows for a child node are shown as if it ran to completion, so actual can be far lower without an error. `BitmapAnd` and `BitmapOr` report their actual rows as zero. Extended statistics help with correlated columns.
- **MySQL and MariaDB**: `EXPLAIN ANALYZE` (8.0.18 and later) runs the statement and reports actual timings; `EXPLAIN FORMAT=JSON` gives cost and detail; the optimizer trace shows why a plan was chosen. `ANALYZE TABLE` refreshes the statistics the optimiser relies on. The `type` column and the `Extra` field are the quickest read.
- **SQLite**: `EXPLAIN QUERY PLAN` gives `SCAN` versus `SEARCH`, `USING INDEX` and `USING COVERING INDEX`, and `USE TEMP B-TREE` for an unindexed sort. Its output is for interactive debugging and the format can change between releases. `ANALYZE` populates the stat tables the planner uses.
- **SQL Server**: `SET STATISTICS IO, TIME ON` reports reads and time; compare the estimated and actual execution plans, and watch for a key lookup, a scan where a seek was expected, or a warning about a cardinality estimate.

## Sources

- PostgreSQL 18 documentation, "Using EXPLAIN": cost units, `actual time` and `loops` being per loop, the estimated-versus-actual row signal, scan and join node meanings, the `LIMIT` early-stop caveat, `BUFFERS`, and that `EXPLAIN ANALYZE` executes the query including side effects. Verified 30 October 2026.
- MySQL 8.4 Reference Manual, "Using EXPLAIN" and "How MySQL Uses Indexes": the `type` access-method ranking, `Extra` values, `EXPLAIN ANALYZE` from 8.0.18, `FORMAT=JSON`, and `ANALYZE TABLE` for statistics. Verified 30 October 2026.
- SQLite documentation, "EXPLAIN QUERY PLAN": `SCAN` versus `SEARCH`, index use and `USE TEMP B-TREE` for an unindexed sort. Verified 30 October 2026.
- Milan Jovanovic, "Why Postgres Ignores Your Index (Sargability)" and a PostgreSQL non-sargable query guide: concrete non-sargable patterns (function and cast on a column, year extraction, leading-wildcard `LIKE`) and their sargable rewrites, and that the fix is to transform the parameter, not the column. Verified 30 October 2026.
- Use The Index, Luke (Markus Winand): matching an index to filtering and ordering, and why wrapping a column defeats the index. Verified 30 October 2026.

The workflow, step by step

1. Reproduce and baseline

Capture the exact SQL and a representative parameter, run it once, and record the time and the number of rows returned. This is the number every later change is measured against. Do not optimise a query you have not timed.

2. Classify the bottleneck before changing anything

Decide which layer is slow, because the fix differs:

  • High database execution time: a plan, index, data or statistics problem. Continue below.
  • Low database time but high end-to-end latency: the application, serialisation or network, not the SQL.
  • Many queries per request: an N+1 access pattern (step 7), not one slow query.
  • A fast query that transfers a huge amount of data: a projection or pagination problem (step 7).
  • Requests waiting on a connection: a pool or concurrency problem, not the query.

Fix the dominant layer. Tuning the SQL will not help a request that is really waiting on a connection pool.

3. Get the plan

Run EXPLAIN to see the planner's chosen plan and its estimates. Then run EXPLAIN ANALYZE to get the actual row counts and run times, but only when it is safe: it executes the query. On PostgreSQL add BUFFERS. For an INSERT, UPDATE, DELETE or MERGE, wrap it so it does not change data:

BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE ...;
ROLLBACK;

4. Read the plan (the signals that matter)

The single most useful signal is the gap between estimated rows and actual rows. If the estimate is off by roughly ten times or more, the planner is working from bad information and will often pick the wrong plan; fix that first (step 5). Then read the operators:

  • PostgreSQL: each node shows cost=startup..total in arbitrary units (not milliseconds), and under ANALYZE, actual time in milliseconds and loops. The per-node actual time and rows are per loop, so multiply by loops for the total. Watch for a Seq Scan on a large table with a selective filter and a high Rows Removed by Filter, or a Sort that dominates the time.
  • MySQL: the type column ranks the access method. ALL is a full table scan and is the usual thing to fix on a large table; ref, range, eq_ref and const are progressively better. Read key (the index used, NULL if none), rows (estimated rows examined), and Extra: Using filesort means a sort with no index, Using temporary means a temp table (often from GROUP BY or DISTINCT), Using index means a covering index.
  • SQLite: EXPLAIN QUERY PLAN shows SCAN for a full scan and SEARCH ... USING INDEX when an index narrows it; USE TEMP B-TREE FOR ORDER BY means the sort was not served by an index.

5. Fix cardinality errors before rewriting

If estimated and actual rows diverge badly, refresh the statistics with ANALYZE (PostgreSQL and SQLite) or ANALYZE TABLE (MySQL) and re-read the plan. For columns that are correlated (city and country, say), single-column statistics mislead the planner; on PostgreSQL create extended statistics. Do not treat "query slow, run ANALYZE" as a reflex: confirm the estimate is actually wrong before you act on it.

6. Make the predicates sargable

A predicate that wraps the column in a function, a cast or arithmetic, or that starts a LIKE with a wildcard, stops the engine using an index and forces a scan. Rewrite so the column stands bare on one side:

  • WHERE EXTRACT(YEAR FROM created_at) = 2026 becomes WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'.
  • WHERE date_trunc('day', created_at) = '2026-07-16' becomes a half-open range on created_at the same way.
  • WHERE price * 1.2 > :limit becomes WHERE price > :limit / 1.2.
  • WHERE CAST(id AS text) LIKE :p and WHERE phone::bigint = :n: cast the constant, not the column.
  • WHERE lower(email) = :e: store the value already normalised, add an expression index on lower(email), or use a case-insensitive type or collation.
  • WHERE name LIKE '%phones' (leading wildcard): a plain B-tree cannot help; use a trigram index, and note it changes which rows match.

These matter once the table is large. On a small table the engine scans anyway, and that is correct.

7. Cut the work the query does

  • Select only the columns you need. SELECT * carries large text and jsonb columns the application never uses and inflates transfer; name the columns.
  • Bound the result with a LIMIT. An unbounded production query is a latent outage.
  • Remove an N+1 pattern (one query, then one more per row) by batching or joining, but do not replace it with a join that multiplies rows and transfers far more than the screen needs.
  • Filter as early as possible, before joins and aggregation, so fewer rows flow through the plan.

8. Fix the dominant operator

Change the one node that dominates the time, then re-measure:

  • A Sort that dominates, under an ORDER BY: an index that matches the ordering lets the engine skip the sort. Hand the column order to database-indexing.
  • A nested loop with a huge loops count and an unindexed inner side: index the inner join key, or let the planner choose a hash or merge join by giving it good statistics. Do not force a join type before you understand why it chose this one.
  • A Seq Scan on a large table with a selective predicate: an index is likely the fix. But a Seq Scan that returns most of the table is correct, so do not force an index onto it.

9. Decide whether an index is really the fix

An index is one fix among several, and often not the first. A rewrite that removes work, a projection that cuts transfer, or fresh statistics can beat a new index and help more queries. Once you have confirmed the bottleneck really is index access, hand the design to database-indexing for the column order, and weigh the write cost there.

10. Re-measure and verify

Re-run EXPLAIN ANALYZE and confirm the plan actually changed and the time dropped against the baseline. Test a second, different parameter value, because a plan that is fast for one distribution can be slow for another. Confirm the results are identical to before unless you changed behaviour on purpose, and check the write impact of any index you added.

Worked example, end to end

A dashboard endpoint runs this on a 20 million row events table and takes about 900 ms:

SELECT *
FROM events
WHERE account_id = :acct
  AND date(created_at) = :day
ORDER BY created_at DESC;

EXPLAIN (ANALYZE, BUFFERS) shows a sequential scan that reads the whole table, discards almost all of it, then sorts:

Sort  (actual time=905.2..905.4 rows=180)
  Sort Key: created_at DESC
  ->  Seq Scan on events  (actual time=0.1..880.6 rows=180 loops=1)
        Filter: (account_id = :acct AND date(created_at) = :day)
        Rows Removed by Filter: 19999820

Three problems. The date(created_at) wraps the column, so no index on created_at can be used (step 6). SELECT * pulls a large payload jsonb column the dashboard does not show (step 7). And there is no index for this access path (step 8).

Fix the query first: make the date filter a half-open range and select only the needed columns.

SELECT id, account_id, created_at, event_type
FROM events
WHERE account_id = :acct
  AND created_at >= :day
  AND created_at <  :day + INTERVAL '1 day'
ORDER BY created_at DESC;

Then, having confirmed the bottleneck is index access, add the index (column order from database-indexing): equality column first, then the range and sort column.

CREATE INDEX idx_events_acct_created
  ON events (account_id, created_at DESC);

The plan is now an index scan that reads only this account's rows for the day, already in order, with no sort, and runs in a few milliseconds:

Index Scan using idx_events_acct_created on events  (actual time=0.05..0.6 rows=180 loops=1)
  Index Cond: (account_id = :acct AND created_at >= :day AND created_at < :day + '1 day')

Re-measured against the 900 ms baseline, verified with a second account, results identical.

Templates you can use verbatim

-- PostgreSQL: estimate, then actual with I/O
EXPLAIN SELECT ...;
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
-- Safe analysis of a data-modifying statement
BEGIN; EXPLAIN (ANALYZE, BUFFERS) UPDATE ...; ROLLBACK;
-- Refresh statistics when estimates are wrong
ANALYZE my_table;                 -- PostgreSQL / SQLite
ANALYZE TABLE my_table;           -- MySQL

-- MySQL: plan, actual timings (8.0.18+), and JSON detail
EXPLAIN SELECT ...;
EXPLAIN ANALYZE SELECT ...;
EXPLAIN FORMAT=JSON SELECT ...;

-- SQLite: which indexes the plan uses
EXPLAIN QUERY PLAN SELECT ...;

-- Sargable rewrites (keep the column bare on one side)
-- year filter:   EXTRACT(YEAR FROM d) = 2026  ->  d >= '2026-01-01' AND d < '2027-01-01'
-- day filter:    date(d) = :day               ->  d >= :day AND d < :day + 1 day
-- arithmetic:    price * 1.2 > :lim           ->  price > :lim / 1.2
-- cast:          CAST(col AS text) = :s        ->  col = CAST(:s AS col_type)
-- case-insensitive: lower(email) = :e          ->  expression index on lower(email), or citext

Failure modes and anti-patterns

  1. Optimising before reading the plan. Spot it when someone adds an index, a cache or a rewrite with no EXPLAIN to justify it. Fix: get the plan first and let the dominant operator and the estimated-versus-actual gap tell you what to change.
  2. Treating a sequential scan as a bug. Spot it when an index is forced onto a query that returns most of the table. Fix: a scan that reads most rows is cheaper than an index and is the right plan; only index a selective predicate on a large table.
  3. Reflex fixes: "slow, so run ANALYZE" or "slow, so add an index". Spot it when the same move is applied to every slow query. Fix: confirm the estimate is actually wrong before ANALYZE, and confirm the bottleneck is index access before adding an index; a rewrite often helps more and more broadly.
  4. Running EXPLAIN ANALYZE on a mutating statement without protection. Spot it when EXPLAIN ANALYZE is run on an UPDATE or DELETE on production: it executes and changes data. Fix: wrap it in BEGIN; ... ROLLBACK;, or analyse a read-only equivalent, and get confirmation before running heavy analysis on production.
  5. Optimising for one lucky parameter. Spot it when a query is tuned with one value (a tiny account, an empty tenant) and regresses for the common case. Fix: test representative and skewed parameter values, because the best plan can differ by distribution.

Quality bar

  • The query was timed before and after, against the same representative parameter.
  • The bottleneck layer was identified (database, application, transfer or connection) before any change.
  • The plan was read, and the estimated-versus-actual row gap was checked and addressed if large.
  • Any predicate that blocked an index was made sargable, and SELECT * was narrowed where it carried unused columns.
  • The dominant operator was the thing changed, and the change was confirmed in a re-run plan with a lower time.
  • A second parameter distribution was tested, results are unchanged, and the write impact of any new index was weighed.
  • EXPLAIN ANALYZE on a data-modifying statement was wrapped in a transaction and rolled back, and heavy or production analysis was confirmed with a human.

Data handling and safety

EXPLAIN ANALYZE executes the query, so running it on an INSERT, UPDATE, DELETE or MERGE changes data: wrap it in BEGIN; ... ROLLBACK; or analyse a read-only equivalent, and get explicit human confirmation before running heavy or mutating analysis against a shared or production database. Adding or dropping an index, if you reach that step, is a schema change: hand it to database-indexing, which treats the DDL as an irreversible action needing confirmation. Optimisation uses the query text, plan output and row-count estimates, not the data values, so refer to columns by name and never paste real personal, health or financial values into prompts or shared plan logs; replace literals with placeholders when sharing a query. Do not run optimisation experiments against production without a go-ahead.

Appendix: engine-specific notes (verify before relying on these)

  • PostgreSQL: costs are in arbitrary units, not time; actual time is in milliseconds and per loop, so multiply by loops. Use EXPLAIN (ANALYZE, BUFFERS) for I/O. pg_stat_statements finds the expensive queries across the workload, and auto_explain logs slow plans. Under LIMIT, estimated rows for a child node are shown as if it ran to completion, so actual can be far lower without an error. BitmapAnd and BitmapOr report their actual rows as zero. Extended statistics help with correlated columns.
  • MySQL and MariaDB: EXPLAIN ANALYZE (8.0.18 and later) runs the statement and reports actual timings; EXPLAIN FORMAT=JSON gives cost and detail; the optimizer trace shows why a plan was chosen. ANALYZE TABLE refreshes the statistics the optimiser relies on. The type column and the Extra field are the quickest read.
  • SQLite: EXPLAIN QUERY PLAN gives SCAN versus SEARCH, USING INDEX and USING COVERING INDEX, and USE TEMP B-TREE for an unindexed sort. Its output is for interactive debugging and the format can change between releases. ANALYZE populates the stat tables the planner uses.
  • SQL Server: SET STATISTICS IO, TIME ON reports reads and time; compare the estimated and actual execution plans, and watch for a key lookup, a scan where a seek was expected, or a warning about a cardinality estimate.

Sources

  • PostgreSQL 18 documentation, "Using EXPLAIN": cost units, actual time and loops being per loop, the estimated-versus-actual row signal, scan and join node meanings, the LIMIT early-stop caveat, BUFFERS, and that EXPLAIN ANALYZE executes the query including side effects. Verified 30 October 2026.
  • MySQL 8.4 Reference Manual, "Using EXPLAIN" and "How MySQL Uses Indexes": the type access-method ranking, Extra values, EXPLAIN ANALYZE from 8.0.18, FORMAT=JSON, and ANALYZE TABLE for statistics. Verified 30 October 2026.
  • SQLite documentation, "EXPLAIN QUERY PLAN": SCAN versus SEARCH, index use and USE TEMP B-TREE for an unindexed sort. Verified 30 October 2026.
  • Milan Jovanovic, "Why Postgres Ignores Your Index (Sargability)" and a PostgreSQL non-sargable query guide: concrete non-sargable patterns (function and cast on a column, year extraction, leading-wildcard LIKE) and their sargable rewrites, and that the fix is to transform the parameter, not the column. Verified 30 October 2026.
  • Use The Index, Luke (Markus Winand): matching an index to filtering and ordering, and why wrapping a column defeats the index. Verified 30 October 2026.

When to use this skill, and when not to

Use it to find out why a query is slow and fix the one thing that is actually slow: read the plan, spot the costly operator, then rewrite the query, refresh the statistics or route to an index. The triggers are a slow query, a timeout, high database CPU, a plan you need to read, or an unexpected full table scan.

Do not use it to design an index’s column order once an index is clearly the fix (that is database-indexing), for keyset pagination (that is efficient-pagination), for join logic (that is sql-joins), or for window frames (that is window-functions). Its defining feature is diagnosis: find the dominant bottleneck first, then make the smallest change that moves it.

Who it is for

Backend and data engineers, analysts and anyone using an AI agent to make a slow query fast. It suits people staring at an EXPLAIN plan they cannot read, a query that is fine on a laptop and slow in production, or a dashboard that times out, who want the real cause rather than a guessed-at index.

What is inside

The skill carries a ten-step workflow with decision rules, not vague advice. It baselines the query, then classifies the bottleneck as a database, application, data-transfer or connection problem before touching the SQL. It reads the plan across PostgreSQL, MySQL and SQLite, treating the gap between estimated and actual rows as the first signal, and it fixes that gap with fresh statistics before rewriting. It gives the sargable rewrites that stop a function or cast on a column defeating an index, cuts work with projections and bounds, fixes the operator that dominates, and only then decides whether an index is the answer. It includes a worked example with real before-and-after plans, copy-and-use commands, five failure modes, a quality bar, and an engine appendix kept apart from the core.

Example prompts

  • “Why is this query slow, here is the EXPLAIN?”
  • “This endpoint times out under load, speed it up.”
  • “What does this query plan mean?”
  • “The query does a full table scan, how do I fix it?”

Example output

The skill returns the bottleneck, the fix and the proof, with the plan to check. A shortened version:

The plan is a Seq Scan removing 19.9M of 20M rows, then a Sort. The date(created_at) filter wraps the column, so no index applies, and SELECT * pulls a large jsonb column. Rewrite the filter to a half-open range, select only the needed columns, then add (account_id, created_at DESC). Re-run EXPLAIN ANALYZE: Index Scan, no Sort, a few milliseconds. Verify with a second account.

What the agent needs from you

The engine and version, the exact query with a representative parameter rather than a toy one, the table sizes, how often it runs and the concurrent load, the current indexes, and whether you can run EXPLAIN ANALYZE safely. The skill asks for the plan output if it cannot run the query itself, because diagnosing from the query text alone is a guess, and it checks whether the statement writes data before running EXPLAIN ANALYZE.

How to install and use it

In the Claude apps, download the SKILL.md from this page, put it in a folder named query-optimization, and upload the folder as a skill in settings. In Claude Code, save it as ~/.claude/skills/query-optimization/SKILL.md, or inside a project under .claude/skills/. In Codex and other Agent Skills tools, drop it in the tool’s skills folder, often .agents/skills/. Then paste the slow query or its plan and the engine; the description triggers it.

How to use query optimisation step by step

  1. Install the skill as query-optimization/SKILL.md.
  2. Give the agent the query, a representative parameter, the engine and the plan if you have it.
  3. It baselines the time, then classifies the bottleneck before changing anything.
  4. It reads the plan and checks the estimated-versus-actual row gap.
  5. It makes predicates sargable, cuts unneeded work, and fixes the dominant operator.
  6. It decides whether an index is the fix, then re-measures and verifies the result.

Worked example

Asked why a dashboard query took about 900 ms on a 20 million row table, the skill read the plan and found a sequential scan discarding almost every row, then a sort. It traced three causes: a date() wrapping the column so no index could apply, a SELECT * pulling a large jsonb payload, and no index for the access path. It rewrote the date filter as a half-open range, selected only the needed columns, and added (account_id, created_at DESC). The re-run plan was an index scan with no sort, a few milliseconds, verified with a second account and identical results.

Situations this skill handles

SituationWhat the skill does
A plan you cannot readReads it across PostgreSQL, MySQL and SQLite and names the costly node
Estimated rows far from actualRefreshes statistics before any rewrite
A function on the column blocks the indexRewrites the predicate to be sargable
SELECT * is slow to transferNarrows the projection to the needed columns
One request fires hundreds of queriesSpots the N+1 pattern and batches or joins it
A sort dominates the planRoutes to an index that serves the order

What makes this skill different

Most query-tuning skills jump to adding an index or a cache. This one diagnoses first: it classifies whether the bottleneck is the database, the application, data transfer or the connection pool before changing a line, and treats the estimated-versus-actual row gap as the first thing to fix. It reads plans across three engines with their real signals (cost units and loops, the type and Extra columns, SCAN versus SEARCH), gives the sargable rewrites with before-and-after SQL, and keeps indexing as a last-informed step handed to a sibling skill rather than a reflex.

Common mistakes

Optimising before reading the plan, treating a sequential scan as a bug and forcing an index onto it, applying “slow, so run ANALYZE” or “slow, so add an index” as a reflex, running EXPLAIN ANALYZE on an UPDATE or DELETE without a transaction so it changes data, and tuning for one lucky parameter while the common case regresses. The skill names each one, shows how to spot it, and says what to do instead.

Hand the column order to Database indexing once you have confirmed an index is the fix. Pair it with SQL joins and Window functions for the queries you are tuning, and with Subqueries and CTEs when a rewrite restructures the query. Browse the full SQL and querying skills set in the Data category.

Frequently asked questions

Run EXPLAIN to see the plan, then EXPLAIN ANALYZE to get the actual times, and read the operator that dominates. The first signal is the gap between estimated and actual rows: a large gap means the planner has bad statistics and is likely choosing the wrong plan. Fix that before rewriting anything.

EXPLAIN shows the plan the engine would use, with estimates, and does not run the query. EXPLAIN ANALYZE actually runs it and reports the real row counts and times alongside the estimates. Because it runs the query, wrap it in BEGIN and ROLLBACK for an INSERT, UPDATE or DELETE so it does not change data.

Usually the predicate is not sargable: a function, cast or arithmetic wraps the column, or a LIKE starts with a wildcard, so the index cannot apply. Other causes are stale statistics or a predicate that matches so many rows a scan is cheaper. Keep the column bare in the WHERE clause and refresh statistics with ANALYZE.

No. A sequential scan that returns most of the table is cheaper than jumping through an index and back, so it is the correct plan. A scan is only a problem when the predicate is selective on a large table, where an index helps. Do not force an index onto a scan that reads most rows.

Only after you have confirmed the bottleneck is index access. A rewrite that removes work, a narrower projection, or fresh statistics often helps more and across more queries, and an index adds a cost on every write. Find the dominant bottleneck first, then design the index with care.

It tells you how well the planner understands your data. When the estimate is far from the actual count, the planner is likely choosing a wrong join, scan or sort. The usual cause is stale or missing statistics, so run ANALYZE, and for correlated columns add extended statistics, then re-read the plan.

Changelog

  • 1.0.0, 30 October 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.