db-query-plan-analyzer · git:20260728.1bce424 · 2026-07-28 · sha256 65c02415faee9031
db-query-plan-analyzer git:20260728.1bce424A
Immutable. This exact content never changes and is served at /api/v1/blob/65c02415faee9031.
--- name: db-query-plan-analyzer description: "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." --- # db-query-plan-analyzer ## Overview Most API perf regressions resolve to one of: 1. A new query running unindexed (sequential scan over a growing table). 2. A query joining on the wrong column type (implicit cast prevents index use). 3. A sort that doesn't fit in `work_mem` and spills to disk. 4. A query plan that started using a nested loop where a hash join would be faster (or vice versa) due to stale stats. 5. A query touching N+1 rows because of an ORM N+1 access pattern. `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][pg-explain]. [pg-explain]: https://www.postgresql.org/docs/current/using-explain.html ## When to use - A load test showed DB-bound latency growth. - An APM tool (Datadog / New Relic / Grafana) flagged a slow query. - A production incident traced to a specific query. - An `EXPLAIN ANALYZE` was captured but the team can't tell which cost is dominant. 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 ```sql 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][pg-explain]). ### MySQL 8.0+ ```sql EXPLAIN ANALYZE SELECT ...; ``` Output is a tree representation similar to PostgreSQL. ### SQLite ```sql 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](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](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][pg-explain] index-creation conventions: ```sql -- 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](references/plan-signatures.md). ## Output format ````markdown ## 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](references/plan-signatures.md) (speculative indexing, reading EXPLAIN without ANALYZE, optimizing a non-dominant node, skipping `ANALYZE` after a bulk load). ## Limitations - **Vendor-specific output.** PostgreSQL's `EXPLAIN` is the most detailed; MySQL 8.0+ caught up; SQLite is sparse. The skill is most useful for postgres-flavored databases. - **Distributed databases.** Spanner, CockroachDB, Citus produce different plans - the skill's heuristics translate but the specific node names differ. - **Doesn't replace a DBA.** For complex multi-CTE queries with recursive sub-plans, a human DBA is faster than this skill's pattern matching. ## References - [pg-explain][pg-explain] - PostgreSQL's canonical `EXPLAIN` / `EXPLAIN ANALYZE` reference. - MySQL EXPLAIN - https://dev.mysql.com/doc/refman/8.0/en/explain.html - SQLite Query Plan - https://www.sqlite.org/eqp.html - "Use the Index, Luke" - https://use-the-index-luke.com/ - a practitioner reference for index design. - `flame-graph-analyzer` - sibling skill for the application-side bottleneck (vs. this skill's database-side focus). - `k6-load-testing` and siblings - load runners that surface DB-bound regressions.