db-query-plan-analyzer
Reads `EXPLAIN` / `EXPLAIN ANALYZE` output from PostgreSQL, MySQL, or SQLite - identifies the dominant cost (sequential scan, nested loop, sort spill, missing index, type-cast preventing index use), proposes the specific index or query rewrite to fix it, and emits the candidate `CREATE INDEX` statement. Use when load testing or production telemetry shows the database as the bottleneck and the team needs targeted query-level remediation.
Install with skills.sh (any agent)
npx skills add testland/qa --skill db-query-plan-analyzerdb-query-plan-analyzer
Overview
Most API perf regressions resolve to one of:
EXPLAIN ANALYZE (PostgreSQL), EXPLAIN ANALYZE (MySQL 8.0+), or EXPLAIN QUERY PLAN (SQLite) reveals which case you're in. This skill reads that output and proposes the fix. The query-plan vocabulary (Sequential Scan, Index Scan, Nested Loop) comes from PostgreSQL's Using EXPLAIN (opens in new window).
When to use
If the bottleneck is connection-pool exhaustion or replication lag, this skill doesn't apply - those are infrastructure concerns.
Step 1 - Get a real EXPLAIN ANALYZE
Always use ANALYZE (it runs the query and reports actual rows + time); plain EXPLAIN gives only planner estimates, which can be wildly wrong.
PostgreSQL
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON, SETTINGS)
SELECT o.id, o.status
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.email = 'test@example.com'
AND o.created_at >= NOW() - INTERVAL '30 days'
ORDER BY o.created_at DESC
LIMIT 50;BUFFERS adds I/O statistics; FORMAT JSON produces machine- readable output; SETTINGS shows session-specific settings that affected the plan (pg-explain (opens in new window)).
MySQL 8.0+
EXPLAIN ANALYZE SELECT ...;Output is a tree representation similar to PostgreSQL.
SQLite
EXPLAIN QUERY PLAN SELECT ...;SQLite's output is simpler - no costs, just the access strategy (Index / Sequential / Search).
Step 2 - Identify the dominant cost
Read the plan from the innermost nodes outward (the deepest nesting is what runs first). The dominant cost is the node with the highest actual time in PostgreSQL or the highest cumulative cost in MySQL. Match the node to its meaning using the cost-signature table in references/plan-signatures.md.
Step 3 - Match cost to fix
Look up the diagnosis in the diagnosis-to-fix table in references/plan-signatures.md (Seq Scan -> add index, type cast -> fix app code, sort spill -> pre-sorted index or larger work_mem, N+1 -> eager-load at the ORM, etc.).
Step 4 - Emit the candidate index / rewrite
Per pg-explain (opens in new window) index-creation conventions:
-- Single-column index on a high-selectivity column
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
-- Composite index - order matters; leading column is the most selective
CREATE INDEX idx_orders_customer_created ON orders(customer_id, created_at DESC);
-- Partial index - covers only the rows the query touches
CREATE INDEX idx_orders_active ON orders(created_at DESC) WHERE status = 'active';
-- Functional index - for predicates with a function on the column
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- Covering index (PostgreSQL 11+) - avoids a heap fetch
CREATE INDEX idx_orders_summary ON orders(customer_id) INCLUDE (status, total);For which column leads, when to use partial vs. functional indexes, and low-cardinality pitfalls, see the index-choice principles in references/plan-signatures.md.
Output format
## Slow query analysis - `<query-id-or-snippet>`
**Database:** postgresql 17 | mysql 8.0 | sqlite
**Query:** (excerpt)
```sql
SELECT o.id, o.status FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE c.email = 'test@example.com' AND o.created_at >= NOW() - INTERVAL '30 days'
ORDER BY o.created_at DESC LIMIT 50;
```
### Plan summary
| Node | Actual time | Rows | Cost driver |
|------------------------------|------------:|------:|-------------|
| Seq Scan on orders (filter) | 2.3s | 1.2M | **DOMINANT** - no index on (customer_id, created_at) |
| Index Scan on customers (email) | 1ms | 1 | (covered) |
### Diagnosis
The query joins `orders` to `customers` on `customer_id`, but
`orders` has no index on `customer_id` - the planner falls back to a
sequential scan over 1.2M rows, then post-filters by date.
### Recommended fix
```sql
CREATE INDEX idx_orders_customer_created ON orders(customer_id, created_at DESC);
```
Composite index ordered by `customer_id` first (the join predicate),
then `created_at` descending - satisfies both the filter and the
ORDER BY without a separate sort.
### Expected impact
- Plan changes from Seq Scan to Index Scan.
- Actual time drops from ~2.3s to ~50ms (rows-touched falls from
1.2M to ~50).
- `Buffers: shared read` drops correspondingly; less I/O on the
database.
### Validation
After applying:
```sql
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
```
Confirm the new plan uses `idx_orders_customer_created` and the
actual time is below the budget.
Anti-patterns
See the anti-patterns table in references/plan-signatures.md (speculative indexing, reading EXPLAIN without ANALYZE, optimizing a non-dominant node, skipping ANALYZE after a bulk load).
Limitations
References
Plan signatures, fixes, and anti-patterns
View source (opens in new window)Plan signatures, fixes, and anti-patterns
Diagnostic lookup tables for db-query-plan-analyzer. Read the plan from the innermost nodes outward; the dominant cost is the node with the highest actual time (PostgreSQL) or highest cumulative cost (MySQL).
Cost signatures
| Signature | Meaning |
|---|---|
Seq Scan on <table> with high actual time | Sequential scan over a large table - likely missing index. |
Index Scan with Filter: and many Rows Removed by Filter | Index used but most rows filtered out - index isn't selective. |
Nested Loop with high inner-side row count | Should likely be a Hash Join - stats may be stale. |
Sort with external merge Disk: | Sort spilled - work_mem too low or sort is unnecessary. |
Bitmap Heap Scan followed by Recheck Cond | Multi-index lookup; usually fine but verify the recheck cost. |
Hash with Batches: <N>, N > 1 | Hash join spilled - work_mem too low. |
PostgreSQL's Buffers: shared hit=N read=M line tells you cache hits vs. disk reads - a high read count is the I/O smoking gun.
Diagnosis to fix
| Diagnosis | Typical fix |
|---|---|
| Seq Scan on a large table, predicate column not indexed | CREATE INDEX ON <table>(<column>). Use a B-tree by default. |
Seq Scan because predicate uses a function on the column (WHERE LOWER(email) = '...') | Functional index: CREATE INDEX ON users (LOWER(email)). |
| Index Scan but many rows filtered post-index | The leading column of the composite index isn't selective enough; reorder the columns or add a column to the predicate that's more selective. |
Type cast in WHERE (e.g. id::text = '123' when id is bigint) | Fix the application code to compare with matching types - the cast disables the index. |
| Sort spill | Add an index that returns pre-sorted data, or raise work_mem for the session. |
| Nested Loop where Hash Join would win | Run ANALYZE <table> to refresh stats; verify the planner switches; if not, file a planner-level investigation. |
| N+1 (separate queries from app code) | Eager-load via JOIN at the ORM layer; not a DB fix at all. |
Index-choice principles
| Principle | Why |
|---|---|
| Leading column = most selective predicate | Composite index columns are used left-to-right; the first column should reduce the result set the most. |
WHERE columns first, then ORDER BY | The index can satisfy filtering AND ordering if the columns align. |
| Use partial indexes for skewed columns | WHERE status = 'active' indexed only for active rows is dramatically smaller than a full index. |
| Functional indexes for non-direct predicates | LOWER(email) etc. - the planner can only use the index if the function matches exactly. |
| Avoid indexing low-cardinality columns alone | A boolean column index isn't helpful - partial index or composite is. |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Adding indexes speculatively | Indexes slow down INSERT / UPDATE; bloat the table; can produce worse plans. | Only add an index that fixes a specific observed slow query, with the EXPLAIN ANALYZE before/after as evidence. |
| Indexing every column | Same as above; the planner can pick a worse index when too many candidates exist. | Composite indexes that cover multiple queries; periodic review of pg_stat_user_indexes to drop unused. |
| Reading EXPLAIN without ANALYZE | Estimated rows can be off by 10x+; the plan you see may not match runtime. | Always ANALYZE in non-prod; use sampling in prod (pg_stat_statements). |
| Optimizing the wrong node | The deepest cost is often a child of the dominant node; fixing the child alone may help marginally. | Always look for the DOMINANT node first (highest actual time, highest cost ratio). |
Skipping ANALYZE <table> after a bulk load | Stale stats produce bad plans even when indexes exist. | Schedule ANALYZE after every bulk-load operation in CI / migrations. |
Related skills
flame-graph-analyzer
Reads CPU flame-graph output from py-spy (Python), async-profiler (JVM), Go pprof, or Node.js `perf_hooks` / clinic.js: identifies the hot path (top sample-time frames), classifies the bottleneck (CPU-bound vs lock contention vs allocator pressure), and proposes the next investigation step. Use when a perf regression is bisected to a commit but the hot path inside it is unclear; for tail-latency percentiles use the latency-percentiles reference in k6-load-testing, and for a slow SQL hot path use db-query-plan-analyzer.
jmeter-load-testing
Authors Apache JMeter `.jmx` test plans (Thread Groups + HTTP samplers + assertions + listeners) in the JMeter GUI, runs them headlessly via `jmeter -n -t plan.jmx -l results.jtl`, generates an HTML dashboard with `-e -o`, and gates CI on JTL parsing. Use when the project has an existing JMeter investment, needs JVM-native load tooling, or works in domains with strong JMeter community support (banking, telecom, enterprise).
k6-load-testing
Authors k6 JavaScript load-test scripts (VU loops + checks + sleeps), configures the `options` block with `stages` (ramp-up patterns) and `thresholds` (p(95) latency, error rate), runs via `k6 run script.js` or `--vus / --duration` ad-hoc flags, and uses thresholds as the CI pass/fail signal. Includes a latency-percentile interpretation reference: tail ratio (p99/p50), bimodal-distribution detection, coordinated omission and why naive p99 is optimistic, and constant-vus vs constant-arrival-rate executors. Use when the project ships HTTP / WebSocket / gRPC load tests and the team wants developer-friendly JavaScript authoring, or when a k6 threshold passes but the system still feels slow.
lighthouse-perf
Configures Lighthouse CI (`@lhci/cli`) to audit Web Vitals (LCP, INP, CLS) on every PR, asserts against canonical thresholds (LCP ≤2.5s, INP ≤200ms, CLS ≤0.1 at the 75th percentile), uploads Lighthouse reports as build artifacts, and posts deltas as PR comments. Includes a budget-authoring reference: per-route LCP/INP/CLS thresholds by traffic class (cached / dynamic / api-heavy / form-heavy / media-heavy) via `assertMatrix`, plus `budget.json` resource-size caps (JS / CSS / images / total bytes). Use when the project ships a web frontend and the team needs continuous Web Vitals monitoring tied to PR gating, or needs its first Lighthouse budgets drafted.
load-testing-overview
Teaches load and performance testing from zero: a tool-selection table choosing between k6, JMeter, Gatling, Locust, and Artillery from observable project facts; the six load profiles (smoke, average-load, stress, spike, soak, breakpoint); open vs closed workload models; why percentiles beat averages; turning a run into a pass/fail CI gate with a first runnable k6 script; a performance-incident triage workflow (confirm with a k6 smoke run, flame-graph the hot path, check slow queries, localize the cause); and full Gatling (Simulation DSL, injectOpen/injectClosed, setUp().assertions()) and Locust (HttpUser + @task locustfile, headless / distributed runs, CSV gating) deep dives in references. Use when a service needs performance coverage and the tool, load profile, or pass/fail threshold has not been decided yet, or when a live performance incident needs cause localization.
perf-budget-gate
Builds a unified release-readiness gate that aggregates verdicts from any combination of k6 / JMeter / Gatling / Locust load runners and Lighthouse CI Web Vitals, applies severity-aware pass/fail thresholds, and emits a single go / no-go decision with per-metric deltas vs the main-branch baseline. Posts the delta as a PR comment when the team has the integration set up. Use when authoring a CI step that gates a deployment on cross-runner perf compatibility.
slo-load-test-plan
Turns a service's SLOs and endpoint traffic mix into a named scenario matrix: one scenario per SLO boundary condition, a load profile (smoke, average-load, stress, soak, spike, breakpoint) per scenario, an open or closed workload injection model, a threshold expression derived from the SLO the scenario guards, and an error-budget calculation that sets the soak run's failure allowance. Stays runner-agnostic and fixes the pass/fail line before any tool is configured. Use when an SLO document and an endpoint list both exist but nobody has decided which load runs to make, what shape of load each carries, or what number would count as a failure.
web-vitals-inp-deep
Deep INP (Interaction to Next Paint) testing: decomposes input delay, processing duration, and presentation delay via the web-vitals/attribution build, asserts per-interaction INP budgets in Playwright using PerformanceObserver plus the web-vitals visibilitychange flush, and identifies long tasks blocking the main thread. Use when a page feels unresponsive while LCP and CLS are green, or to gate key interactions (form submit, modal open, route change) under an INP budget in CI. Covers interactions only - for page-load Web Vitals gating use lighthouse-perf; for service-worker cache-strategy latency use the qa-pwa plugin's service-worker skills.