skills/postgres-explain-analyzer/SKILL.md
Use when a Postgres query is slow, p99 latency regressed after deploy, EXPLAIN ANALYZE output needs interpretation, the planner is choosing the wrong index, dead tuples are blowing up, autovacuum is falling behind, pg_stat_statements review is needed, or query plans regressed between releases. Triggers: "Seq Scan on big_table", "Rows Removed by Filter: 3M", "could not extend file", parameter-sniffing surprises, missing indexes, prepared-statement plan-cache regressions. NOT for schema design from scratch, replication setup, MySQL/Aurora-specific tuning, or pgvector index tuning — different skill domains.
npx skillsauth add curiositech/windags-skills postgres-explain-analyzerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Postgres tells you exactly what it's doing. Most "slow query" issues are the planner choosing a bad path because statistics are stale or the query forces it. EXPLAIN ANALYZE with BUFFERS is the single best diagnostic tool the database ships with.
flowchart TD
A[Query slow] --> B[EXPLAIN ANALYZE BUFFERS, FORMAT TEXT]
B --> C{Estimated rows ≈ actual rows?}
C -->|No, off by 10x+| F1[FIX: ANALYZE table or raise default_statistics_target on column]
C -->|Yes| D{Seq Scan on big table with selective filter?}
D -->|Yes| F2[FIX: index on filter column; partial index if condition is constant]
D -->|No| E{Rows Removed by Filter > Rows kept by 100x?}
E -->|Yes| F3[FIX: index covers filter; or extend index to include filter cols]
E -->|No| G{Buffers shared read >> hit?}
G -->|Yes| F4[FIX: working set evicted; increase shared_buffers or shrink result via covering index]
G -->|No| H{Sort uses external Disk?}
H -->|Yes| F5[FIX: raise work_mem for this query, or add ORDER BY index]
H -->|No| I{Plan changes between runs with same shape?}
I -->|Yes| F6[FIX: parameter sniffing — review prepared-statement plan cache or pg_hint_plan]
I -->|No| J[Check pg_stat_statements + autovacuum lag]
pg_stat_user_tables.seq_scan is climbing for a table you indexed.pg_stat_activity shows autovacuum:) are noisy.pg_stat_user_tables.n_dead_tup blowup).Run with BUFFERS so you see I/O. Always.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id FROM orders WHERE customer_id = 42 AND created_at > now() - interval '7 days';
Index Scan using orders_customer_created_idx on orders
(cost=0.43..152.18 rows=37 width=8) (actual time=0.024..0.412 rows=39 loops=1)
Index Cond: ((customer_id = 42) AND (created_at > (now() - '7 days'::interval)))
Buffers: shared hit=12 read=2
Planning Time: 0.187 ms
Execution Time: 0.451 ms
What each piece means:
cost=startup..total — abstract planner units. Compare costs between plans, never use them as a latency unit.rows=N — planner's estimate. If actual rows is 100x off, statistics are stale or the predicate is poorly modeled.width=W — average bytes per row. Matters for sort/hash memory estimates.actual time=startup..total — milliseconds. Multiply by loops for true total in nested loops.Buffers: shared hit=H read=R — pages from cache (hit) vs disk (read). read numbers blow up I/O latency.Rows Removed by Filter: N — rows touched but discarded. High here means the index isn't selective enough; consider a covering or partial index.| Node | When it's good | When it's bad |
|------|----------------|----------------|
| Seq Scan | Small tables, or you need >20% of rows. | Big tables where a selective predicate exists. |
| Index Scan | Selective predicates, ordered output. | Hot inner loop with millions of iterations. |
| Index Only Scan | Covering index hits, vacuumed table. | Table not vacuumed → many heap fetches anyway. |
| Bitmap Heap Scan | Multiple indexes combined, medium-selectivity. | "Recheck Cond" doing a lot of work. |
| Nested Loop | Tiny outer side (< 1000 rows). | Large outer side without an index on inner. |
| Hash Join | Both sides fit in memory. | Spills to disk → look at work_mem. |
| Merge Join | Both sides already sorted (or indexed). | Forced sort step is expensive. |
| Sort | Small N, or supports a Merge Join. | "Sort Method: external merge Disk: 200MB" → bump work_mem. |
| Hash Aggregate | Distinct/group by with bounded keys. | OOM if estimate was wrong. |
ANALYZE schema.table; is the cheapest first move.CREATE STATISTICS orders_cust_status (dependencies)
ON customer_id, status FROM orders;
ANALYZE orders;
Use mcv for skewed categorical data, ndistinct for combinations.WHERE lower(email) = 'x' can't use a btree on email. Build an expression index or store normalized.WHERE bigint_col = '42' → cast forces seq scan in some versions. Match types explicitly.SET plan_cache_mode = force_custom_plan; per session.jsonb containment, full-text, array overlap. Big indexes; expensive writes; fast lookups.WHERE active on a status column, only the active rows are indexed. Dramatically smaller.((lower(email))) to support case-insensitive lookups.INCLUDE) — extra payload columns avoid heap fetches for Index Only Scan.ALTER TABLE hot_writes SET (
autovacuum_vacuum_scale_factor = 0.05, -- default 0.2
autovacuum_vacuum_cost_limit = 2000 -- default 200; allow more I/O per pass
);
Watch:
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;
If n_dead_tup / n_live_tup > 0.2, you'll start seeing seq scans on what should be indexed reads. HOT updates (no indexed column changing, fillfactor leaves room) reduce this dramatically. Set fillfactor = 80 on hot-update tables.
SELECT queryid, calls, total_exec_time, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Track regressions by queryid (stable across parameters). After a deploy, snapshot top-20 by total_exec_time; alarm on any new entry or any 2x mean_exec_time growth.
Symptom: EXPLAIN still shows Seq Scan after CREATE INDEX.
Diagnosis: Type mismatch (text column, varchar(50) parameter binding), function wrapping (WHERE date(ts) = …), or selectivity too low (planner correctly chose a seq scan because the index would return 60% of rows).
Fix: Check pg_index for the index, run ANALYZE, verify types match, and if it's selectivity, accept the seq scan or add a partial index.
Symptom: Local EXPLAIN ANALYZE runs in 5ms; prod query latency p99 is 800ms.
Diagnosis: Parameter sniffing on a prepared statement that locked a generic plan, or an autovacuum lull leaving 30M dead tuples on a table you sampled small.
Fix: Run EXPLAIN (ANALYZE, BUFFERS) EXECUTE prep(…) against prod. If buffers/timings differ, force a custom plan or fix vacuum.
Symptom: Plan shows Recheck Cond: reading thousands of pages.
Diagnosis: Bitmap index returned more candidates than work_mem could hold; index is lossy (gin) or selectivity dropped.
Fix: Bump work_mem for the session, or replace gin with a btree on a normalized column.
Symptom: Index unused on email despite a query like WHERE lower(email) = 'x'.
Diagnosis: Postgres can't prove the function is index-eligible without an expression index.
Fix: Either query without the function (case-fold at write time and store normalized) or CREATE INDEX ON users (lower(email)).
Symptom: Insert latency rising; pg_stat_user_indexes.idx_scan = 0 for several indexes.
Diagnosis: Every index pays a write cost; unused ones are pure overhead.
Fix: Drop indexes with idx_scan = 0 and pg_relation_size > 100MB. Keep evidence: capture pg_stat_user_indexes for 30 days first.
Symptom: n_dead_tup > n_live_tup; query latency rises during business hours.
Diagnosis: Default autovacuum thresholds (20% dead tuples) too lax for write-heavy tables.
Fix: Per-table autovacuum_vacuum_scale_factor = 0.05, raise autovacuum_vacuum_cost_limit, set fillfactor = 80 to enable HOT updates.
Scenario. Tuesday deploy, by 3pm the orders API p99 latency jumped from 80ms to 1.4s. Rollback would lose unrelated fixes. Pager has been firing for 90 minutes.
Novice would: Open the slow query log, see a SELECT * FROM orders WHERE customer_id = $1 ORDER BY created_at DESC LIMIT 50 taking 800ms, add an index on customer_id. Maybe it helps, maybe customer_id was already indexed and the issue is elsewhere.
Expert catches:
EXPLAIN (ANALYZE, BUFFERS) SELECT ... against a live replica. Plan shows Index Scan on customer_id BUT Rows Removed by Filter: 8423. The filter is status = 'open' post-index-fetch.pg_stat_statements shows the same query was hitting idx_orders_customer_status (composite) before deploy. After deploy, planner switched to idx_orders_customer_id — the other index got dropped in a "cleanup" migration.EXPLAIN estimates 50 rows; actuals are 8473. ANALYZE is fresh, but the stats target on status is too low — 'open' is 1% of rows, but the histogram only has 100 buckets.CREATE INDEX CONCURRENTLY idx_orders_customer_status ON orders (customer_id, status, created_at DESC) — restores the lost composite. (b) ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000 so future planner decisions are stable.Index Scan on the composite, no Rows Removed, 4ms.Timeline. Novice's customer_id-only index helps a bit (~400ms p99), incident drags into the evening as other queries hit similar issues. Expert restores the composite + raises stats in 20 minutes; p99 is back under 100ms. The deploy review then catches the migration as the regression source and adds an idx_scan audit guard before drops.
EXPLAIN (ANALYZE, BUFFERS) saved alongside the query (in runbooks/queries/ or as a regression-test fixture).Seq Scan on tables >100k rows in any query path under 100ms p99. Verified by pg_stat_statements query against pg_class.relpages.pg_stat_user_tables.n_dead_tup / n_live_tup < 0.2 on hot tables. Alert in grafana-dashboard-builder panel.pg_stat_statements top-20 reviewed weekly; new entries explained. Capture in weekly-db-review.md.idx_scan = 0 and size > 100MB are documented or dropped within 30 days. Verified by a scheduled query against pg_stat_user_indexes.work_mem sized so the worst common Sort/Hash node doesn't spill to disk. Verified: grep EXPLAIN output for Disk: — should be absent in p99 query plans.pg_hint_plan or rewritten to be selectivity-stable. Documented in runbook.d1-and-supabase-migrations.grafana-dashboard-builder.data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.