Query optimization
An index added on a hunch is a permanent write cost bought with no evidence. The work here is
measurement: the plan before, one change, the plan after, on data that resembles the data that is
actually slow.
When this fires
A specific statement is slow, or a plan needs interpreting, or a proposed index needs justifying.
It does not fire before the database has been shown to be where the time goes — if that is still
open, measure the request end to end first. It does not fire for redesigning the tables.
Procedure
- Get the exact statement and its parameters. Not "the dashboard is slow" — the SQL text as
the database received it, with the bind values, and where it runs from. A statement-statistics
view, the ORM's query log or the application log will have it. The parameters matter: the same
query can take different plans for different values.
- Confirm the database is the bottleneck. Compare the statement's own time against the total
request time. If the query is 40ms of a 3s request, stop here and say so. Note whether the cost
is one slow statement or many fast ones — an N+1 is fixed in the calling code, not by an index.
- Reproduce it on comparable data. Row counts, value distribution and index state have to
resemble production, because a table small enough to sit in memory makes every plan look fine.
If comparable data is not available, that limitation is the headline of your report, not a
footnote.
- Capture the plan before changing anything, with actual execution and buffer statistics, and
keep the output. This is your before, and without it there is no after. Where the statement
modifies data, run it inside a transaction you roll back — and on a shared or production
database, ask before running anything at all.
- Read the plan for the node that actually costs, not the top line. Work from the largest
actual time, remembering that a node's reported time is per loop and multiplies by its loop
count. Then compare estimated rows against actual rows at that node: a large gap means the
planner's information is wrong, and fixing the information often beats fixing the query.
- Classify what you found before proposing anything. A sequential scan of a large table under
a selective predicate points at a missing or unusable index. A huge row count discarded by a
filter after an index scan points at the wrong index. A nested loop with an enormous loop count
usually points at a bad estimate upstream. A sort or hash spilling to disk points at memory
limits or an avoidable sort. A function evaluated per row points at the expression, not the
index.
- Try the cheapest fix first. Remove work before adding structure: fewer columns, fewer rows,
a bounded result, a join that was never needed, a repeated query collapsed into one. Then make
the predicate usable by an index — a column wrapped in a function cannot use an ordinary index
on that column, so either unwrap it or index the expression. Only then add or adjust an index.
Materialized or denormalized copies come after that, and engine configuration last.
- Choose the index to match the predicate and the ordering. Equality columns come before range
columns in a multicolumn index; the ordering the query needs can remove a sort. A partial index
fits a query that always carries the same filter. Before creating anything, list the existing
indexes and check whether one already covers the access pattern — a near-duplicate index is
pure cost.
- Change one thing and re-measure identically. Same data, same parameters, same method,
several runs. Report cold and warm separately or not at all, because the second run of anything
is faster and that difference is not your fix.
- Check what else moved. An index changes write latency and can change plans for other
statements. If you dropped or replaced one, name the queries that were using it. If the fix was
a schema or configuration change, say what else it touches.
- Stop before applying it to a shared database. Creating, dropping or rebuilding an index on
a live system takes locks and time. Hand over the statement, the expected lock behaviour, and
the measured benefit, and let the decision to run it be made explicitly.
Checklist
Failure handling
- The query is fast when you run it. Something else is the real difference — parameters, cache
state, concurrency, connection setup, or the client fetching every row. Do not conclude "no
problem found"; report what you measured and what still differs from the slow environment.
- Only a small dataset is available. Say it. Results from a table that fits in memory are not
transferable, and an index recommendation from one is a guess wearing a measurement's clothes.
- Estimates are far from actuals. Refresh statistics and re-plan before touching indexes. Stale
statistics produce bad plans that new indexes will not repair, and correlated columns need a
different remedy from a missing index.
- The improvement is within run-to-run noise. It is not an improvement. Run more iterations or
drop the change.
- The real fix is the data model. Say so, and do not paper over it with indexes — hand it to
the schema work with the plan as evidence.
- No access to production-like data or plans. Report the query as analyzed, not as
optimized. An unmeasured change is a proposal.
Evidence to report
The statement and parameters; the before and after plans as output, not paraphrase; timings with
the number of runs and the cache state; the dataset's row counts; the single change made; existing
indexes considered; write-side and cross-query effects; anything left unmeasured. Keep the words
honest — analyzed, changed, measured and deployed describe four different states, and only
a measured before and after supports the word faster.
1---2name: query-optimization3description: Make one slow query fast without guessing — capture the plan, find where the time actually goes, change one thing, and measure again on comparable data. Use when a query, endpoint or report is slow and the database is the suspect, when a plan needs reading, or when someone proposes an index with no evidence. Not for modelling decisions about tables and constraints, not an engine feature reference, and not for system-wide performance work where the database has not yet been shown to be the bottleneck.4---56# Query optimization78An index added on a hunch is a permanent write cost bought with no evidence. The work here is9measurement: the plan before, one change, the plan after, on data that resembles the data that is10actually slow.1112## When this fires1314A specific statement is slow, or a plan needs interpreting, or a proposed index needs justifying.15It does not fire before the database has been shown to be where the time goes — if that is still16open, measure the request end to end first. It does not fire for redesigning the tables.1718## Procedure19201. **Get the exact statement and its parameters.** Not "the dashboard is slow" — the SQL text as21 the database received it, with the bind values, and where it runs from. A statement-statistics22 view, the ORM's query log or the application log will have it. The parameters matter: the same23 query can take different plans for different values.242. **Confirm the database is the bottleneck.** Compare the statement's own time against the total25 request time. If the query is 40ms of a 3s request, stop here and say so. Note whether the cost26 is one slow statement or many fast ones — an N+1 is fixed in the calling code, not by an index.273. **Reproduce it on comparable data.** Row counts, value distribution and index state have to28 resemble production, because a table small enough to sit in memory makes every plan look fine.29 If comparable data is not available, that limitation is the headline of your report, not a30 footnote.314. **Capture the plan before changing anything**, with actual execution and buffer statistics, and32 keep the output. This is your *before*, and without it there is no after. Where the statement33 modifies data, run it inside a transaction you roll back — and on a shared or production34 database, ask before running anything at all.355. **Read the plan for the node that actually costs**, not the top line. Work from the largest36 actual time, remembering that a node's reported time is per loop and multiplies by its loop37 count. Then compare estimated rows against actual rows at that node: a large gap means the38 planner's information is wrong, and fixing the information often beats fixing the query.396. **Classify what you found before proposing anything.** A sequential scan of a large table under40 a selective predicate points at a missing or unusable index. A huge row count discarded by a41 filter after an index scan points at the wrong index. A nested loop with an enormous loop count42 usually points at a bad estimate upstream. A sort or hash spilling to disk points at memory43 limits or an avoidable sort. A function evaluated per row points at the expression, not the44 index.457. **Try the cheapest fix first.** Remove work before adding structure: fewer columns, fewer rows,46 a bounded result, a join that was never needed, a repeated query collapsed into one. Then make47 the predicate usable by an index — a column wrapped in a function cannot use an ordinary index48 on that column, so either unwrap it or index the expression. Only then add or adjust an index.49 Materialized or denormalized copies come after that, and engine configuration last.508. **Choose the index to match the predicate and the ordering.** Equality columns come before range51 columns in a multicolumn index; the ordering the query needs can remove a sort. A partial index52 fits a query that always carries the same filter. Before creating anything, list the existing53 indexes and check whether one already covers the access pattern — a near-duplicate index is54 pure cost.559. **Change one thing and re-measure identically.** Same data, same parameters, same method,56 several runs. Report cold and warm separately or not at all, because the second run of anything57 is faster and that difference is not your fix.5810. **Check what else moved.** An index changes write latency and can change plans for other59 statements. If you dropped or replaced one, name the queries that were using it. If the fix was60 a schema or configuration change, say what else it touches.6111. **Stop before applying it to a shared database.** Creating, dropping or rebuilding an index on62 a live system takes locks and time. Hand over the statement, the expected lock behaviour, and63 the measured benefit, and let the decision to run it be made explicitly.6465## Checklist6667- [ ] Exact statement and parameters captured68- [ ] The database was shown to be the bottleneck, with the share of total time69- [ ] Dataset size and distribution stated, and their comparability to production assessed70- [ ] Before plan captured with actual execution statistics and kept71- [ ] The expensive node identified, with estimated-versus-actual rows read72- [ ] Exactly one change made per measurement73- [ ] Existing indexes listed before a new one was proposed74- [ ] After plan and timings captured the same way as the before75- [ ] Write cost and effects on other queries considered76- [ ] Nothing applied to a shared or production database without authorization7778## Failure handling7980- **The query is fast when you run it.** Something else is the real difference — parameters, cache81 state, concurrency, connection setup, or the client fetching every row. Do not conclude "no82 problem found"; report what you measured and what still differs from the slow environment.83- **Only a small dataset is available.** Say it. Results from a table that fits in memory are not84 transferable, and an index recommendation from one is a guess wearing a measurement's clothes.85- **Estimates are far from actuals.** Refresh statistics and re-plan before touching indexes. Stale86 statistics produce bad plans that new indexes will not repair, and correlated columns need a87 different remedy from a missing index.88- **The improvement is within run-to-run noise.** It is not an improvement. Run more iterations or89 drop the change.90- **The real fix is the data model.** Say so, and do not paper over it with indexes — hand it to91 the schema work with the plan as evidence.92- **No access to production-like data or plans.** Report the query as *analyzed*, not as93 *optimized*. An unmeasured change is a proposal.9495## Evidence to report9697The statement and parameters; the before and after plans as output, not paraphrase; timings with98the number of runs and the cache state; the dataset's row counts; the single change made; existing99indexes considered; write-side and cross-query effects; anything left unmeasured. Keep the words100honest — *analyzed*, *changed*, *measured* and *deployed* describe four different states, and only101a measured before and after supports the word *faster*.