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.
By Afeez Adebayo, Marketing, SEO & Growth Expert with 10+ years of experience.
Created 30 October 2026. Updated 30 October 2026. Free to use under CC BY 4.0.
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
- The SQL engine and version. Plan output and the fixes differ (see the appendix).
- 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.
- The table sizes in rows, how often the query runs, and the concurrent load.
- The current indexes on the tables involved.
- Whether you can run
EXPLAIN ANALYZEsafely: this is production or a copy, and the statement is aSELECTor a data-modifying statement. - 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
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..totalin arbitrary units (not milliseconds), and underANALYZE,actual timein milliseconds andloops. The per-nodeactual timeandrowsare per loop, so multiply byloopsfor the total. Watch for aSeq Scanon a large table with a selective filter and a highRows Removed by Filter, or aSortthat dominates the time. - MySQL: the
typecolumn ranks the access method.ALLis a full table scan and is the usual thing to fix on a large table;ref,range,eq_refandconstare progressively better. Readkey(the index used,NULLif none),rows(estimated rows examined), andExtra:Using filesortmeans a sort with no index,Using temporarymeans a temp table (often fromGROUP BYorDISTINCT),Using indexmeans a covering index. - SQLite:
EXPLAIN QUERY PLANshowsSCANfor a full scan andSEARCH ... USING INDEXwhen an index narrows it;USE TEMP B-TREE FOR ORDER BYmeans 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) = 2026becomesWHERE 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 oncreated_atthe same way.WHERE price * 1.2 > :limitbecomesWHERE price > :limit / 1.2.WHERE CAST(id AS text) LIKE :pandWHERE phone::bigint = :n: cast the constant, not the column.WHERE lower(email) = :e: store the value already normalised, add an expression index onlower(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 andjsonbcolumns 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
Sortthat dominates, under anORDER BY: an index that matches the ordering lets the engine skip the sort. Hand the column order todatabase-indexing. - A nested loop with a huge
loopscount 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 Scanon a large table with a selective predicate: an index is likely the fix. But aSeq Scanthat 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: 19999820Three 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 citextFailure modes and anti-patterns
- Optimising before reading the plan. Spot it when someone adds an index, a cache or a rewrite with no
EXPLAINto justify it. Fix: get the plan first and let the dominant operator and the estimated-versus-actual gap tell you what to change. - 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.
- 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. - Running EXPLAIN ANALYZE on a mutating statement without protection. Spot it when
EXPLAIN ANALYZEis run on anUPDATEorDELETEon production: it executes and changes data. Fix: wrap it inBEGIN; ... ROLLBACK;, or analyse a read-only equivalent, and get confirmation before running heavy analysis on production. - 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 ANALYZEon 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 timeis in milliseconds and per loop, so multiply byloops. UseEXPLAIN (ANALYZE, BUFFERS)for I/O.pg_stat_statementsfinds the expensive queries across the workload, andauto_explainlogs slow plans. UnderLIMIT, estimated rows for a child node are shown as if it ran to completion, so actual can be far lower without an error.BitmapAndandBitmapOrreport 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=JSONgives cost and detail; the optimizer trace shows why a plan was chosen.ANALYZE TABLErefreshes the statistics the optimiser relies on. Thetypecolumn and theExtrafield are the quickest read. - SQLite:
EXPLAIN QUERY PLANgivesSCANversusSEARCH,USING INDEXandUSING COVERING INDEX, andUSE TEMP B-TREEfor an unindexed sort. Its output is for interactive debugging and the format can change between releases.ANALYZEpopulates the stat tables the planner uses. - SQL Server:
SET STATISTICS IO, TIME ONreports 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 timeandloopsbeing per loop, the estimated-versus-actual row signal, scan and join node meanings, theLIMITearly-stop caveat,BUFFERS, and thatEXPLAIN ANALYZEexecutes the query including side effects. Verified 30 October 2026. - MySQL 8.4 Reference Manual, "Using EXPLAIN" and "How MySQL Uses Indexes": the
typeaccess-method ranking,Extravalues,EXPLAIN ANALYZEfrom 8.0.18,FORMAT=JSON, andANALYZE TABLEfor statistics. Verified 30 October 2026. - SQLite documentation, "EXPLAIN QUERY PLAN":
SCANversusSEARCH, index use andUSE TEMP B-TREEfor 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.
On this page
- When to use this skill, and when not to
- Who it is for
- What is inside
- Example prompts
- Example output
- What the agent needs from you
- How to install and use it
- How to use query optimisation step by step
- Worked example
- Situations this skill handles
- What makes this skill different
- Common mistakes
- Related skills
- Frequently asked questions
- Changelog
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
- Install the skill as
query-optimization/SKILL.md. - Give the agent the query, a representative parameter, the engine and the plan if you have it.
- It baselines the time, then classifies the bottleneck before changing anything.
- It reads the plan and checks the estimated-versus-actual row gap.
- It makes predicates sargable, cuts unneeded work, and fixes the dominant operator.
- 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
| Situation | What the skill does |
|---|---|
| A plan you cannot read | Reads it across PostgreSQL, MySQL and SQLite and names the costly node |
| Estimated rows far from actual | Refreshes statistics before any rewrite |
| A function on the column blocks the index | Rewrites the predicate to be sargable |
| SELECT * is slow to transfer | Narrows the projection to the needed columns |
| One request fires hundreds of queries | Spots the N+1 pattern and batches or joins it |
| A sort dominates the plan | Routes 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.
Related skills
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
How do I find out why a SQL query is slow?
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.
What is the difference between EXPLAIN and EXPLAIN ANALYZE?
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.
Why is my index being ignored by the query?
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.
Is a full table scan always bad?
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.
Should I add an index to speed up a slow query?
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.
What does estimated rows versus actual rows tell me?
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.
