sql-optimizer
Skill0 starsChanged 5 months agoMITView on GitHub
Diagnose and fix slow SQL — read EXPLAIN ANALYZE plans, find the worst node, propose the right indexes (composite, covering, partial), detect N+1 / OFFSET / LIKE-%prefix anti-patterns, and ship a re-measure protocol. Supports PostgreSQL and MySQL. Outputs a verdict, plan diagnosis, ordered fix plan with DDL, and risk notes. Use to make a slow query fast, audit an EXPLAIN plan, or pre-deployment review of a schema/index change.
8 other projects use a version of this.
Adopted by
Most recently taken up by
- jaskirat1616/grok-skillstheir own version, first seen Nov 1, 2026
- Mathews-Tom/armorytheir own version, first seen Nov 1, 2026
- milad-ahmd/agent-skillstheir own version, first seen Nov 1, 2026
- JayRHa/agent-skillstheir own version, first seen Oct 15, 2026
- minagayid/Agentstheir own version, first seen Jul 5, 2026
- BleckWolf25/AIToolkittheir own version, first seen Jul 4, 2026
Used with
Projects with it also use changelog-keeper, 0-template and 10 more.
Other files people use alongside this one.
Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
JayRHaSkillchangelog-keeper
Maintains a CHANGELOG.md in the Keep a Changelog format with Semantic Versioning, grouping user-facing entries under Added/Changed/Deprecated/Removed/Fixed/Security and cutting dated releases from an Unreleased section. Use this skill when the user asks to "update the changelog", "add a changelog entry", "cut a release", "bump the version", "what changed since the last release", or to create/clean up a CHANGELOG.md or release notes.
Also in 4 projects that use this
JayRHaSkill0-template
Template for creating a new Agent Skill. Copy this folder, rename it to your skill's kebab-case name, and fill in every section. Use this as the starting point whenever you author a new skill for the AgentSkills library.
Also in 3 projects that use this
xmqywxSkillmeeting-summarizer
Transform meeting transcripts into structured summaries with action items, decisions, follow-ups, and efficiency analysis. Works with any meeting format.
Also in 3 projects that use this
MehdibargachSkillokr-writer
Turn business goals into structured OKRs with measurable Key Results, linked initiatives, alignment check, and anti-goals. Takes a strategic priority and outputs ready-to-use OKRs.
Also in 3 projects that use this
pbc-osSkillsecrets-manager
Set up and configure cloud secrets management for your AI agent. Guides users through platform selection (GCP, AWS, Azure, 1Password, Doppler, HashiCorp Vault), account setup, CLI configuration, and secure credential storage.
Also in 3 projects that use this
- +7 more from JayRHa
Other versions8 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 8
- Mathews-Tom/armory★ 340same name
- jaskirat1616/grok-skills★ 22same name
- JayRHa/agent-skills★ 12same name
- JayRHa/AgentSkills★ 12same name
- milad-ahmd/agent-skills★ 5same name
- juanlurg/data-science-claude-skills★ 3same name
- BleckWolf25/AIToolkit★ 1same name
- minagayid/Agents★ 1same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
The file
We split this skill into the rules it gives an agent
10 rules. 0 are common, 5 are distinctive, and 5 are very specific to this project.
0
Common
Most of the 8 edited versions we read keep these.
5
Distinctive
This author's own choices. Worth a look.
5
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 60
Before reading a single plan line, establish exactly what you're tuning.
- Very specific: line 76
The
EXPLAINoutput, if any: Prefer EXPLAIN (ANALYZE, BUFFERS) in PG and EXPLAIN ANALYZE or EXPLAIN FORMAT=JSON in MySQL 8+. - Distinctive: line 87
If any of these are missing, ask — don't invent them.
- Distinctive: line 113
Every PostgreSQL and MySQL plan is built from a small set of operators.
- Very specific: line 135
Plan Rows vs Actual Rows mismatch ⇒ stale statistics: Look at every node and compare the planner's rows= estimate to the actual rows the …
- Very specific: line 141
Rows Removed by Filteris the over-fetching signal: If a node returned 50 rows but Rows Removed by Filter: 1,200,000, the planner scanned … - Very specific: line 210
If 95% of orders are complete and you only ever query pending, a partial index is smaller, hotter in cache, and faster than a full index on …
- Distinctive: line 422
Run this checklist over any plan:
- Distinctive: line 438
If any box is ticked, name it in the diagnosis.
- Very specific: line 531
Write amplification: Every new index slows every INSERT / UPDATE / DELETE that touches its keys.
“Common” is measured: kept by at least 6 in 10 of the 8 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
SQL Optimizer
A disciplined, repeatable methodology for making slow SQL fast — read the
EXPLAIN ANALYZE plan, find the single worst node, hypothesize the
single fix that moves it, ship it, and re-measure. The skill is grounded
in how senior DBAs actually work: small, ordered, evidence-driven changes —
not a shotgun blast of CREATE INDEX statements.
Most "slow query" tickets come down to one of a small number of patterns: a missing or wrong-order composite index, a stale-statistics cardinality miss, a sort spilling to disk, or an N+1 in the app layer. This skill encodes the workflow, the plan-reading glossary, the indexing rules (leftmost-prefix, covering, partial, expression), the anti-patterns, and a red-flag scan into a single repeatable pass.
When to Activate
Activate when the user:
- Shares a slow SQL query and asks why it's slow or how to speed it up.
- Pastes an
EXPLAIN ANALYZE(orEXPLAIN FORMAT=JSON) output and asks you to read it. - Shares a schema / index DDL diff for pre-deployment review ("should we add this index?", "is this column order right?").
- Pastes an ORM-generated query (Django, Rails, SQLAlchemy, Hibernate, Prisma, EF Core) and asks why it's slow — or shares logs that look like N+1.
- Shares a
pg_stat_statementsrow, MySQL slow query log entry, or an APM trace with a hot SQL call. - Asks any of: "why is this query slow?", "what index do I need?", "is this an N+1?", "how do I read this plan?", "is my composite index in the right order?"
Honest scope & limits — read first.
- I cannot run the query against a live database. I reason over what you provide: the query, the
EXPLAINoutput, the schema/DDL, row counts, sample data, and engine/version.- Plans are data- and config-dependent. A plan on a 1k-row dev table says nothing about a 100M-row prod table — always re-run
EXPLAIN ANALYZEon prod-sized data.- Index changes are not free: every index slows writes and consumes buffer cache. Verify the win before shipping.
- ⚠️
EXPLAIN ANALYZEon anINSERT/UPDATE/DELETEactually performs the write. Wrap inBEGIN; ... ROLLBACK;or run on a clone.- This skill is not a substitute for proper APM + slow-query monitoring in production.
Step 1: Intake & Scope
Before reading a single plan line, establish exactly what you're tuning.
- Engine and version. PostgreSQL 15? MySQL 8.0? SQLite? SQL Server? This skill goes deepest on PostgreSQL and MySQL 8+, with coverage of SQLite/SQL Server where the workflow is the same. Version matters: PG 12+ inlines CTEs by default, MySQL 8 added hash join and histograms, etc.
- The query itself. The exact SQL — not a paraphrase. ORM-generated?
Capture the actual SQL it emits (
django.db.connection.queries, RailsActiveRecord::Base.logger, Hibernateshow_sql=true, etc.). - The schema.
CREATE TABLEfor every table in the query, plus every existing index (\d+ tablenamein PG,SHOW CREATE TABLEin MySQL). Without this you cannot tell what's missing. - Cardinalities. Row count per table, roughly. "Big" means
different things at 10M vs 10B. For the worst query, also: rows after
each predicate (
SELECT count(*) FROM t WHERE …). - The
EXPLAINoutput, if any. PreferEXPLAIN (ANALYZE, BUFFERS)in PG andEXPLAIN ANALYZEorEXPLAIN FORMAT=JSONin MySQL 8+. PlainEXPLAINis a guess;ANALYZEruns the query for real. (See the DML warning above.) - What "slow" means. Current p50/p95 latency. Target latency. Is this a hot OLTP query (every web request) or an analytical query (nightly job)? The fix calculus is different.
- Workload context. Reads vs writes ratio, prepared statements, app connection pool size, replication setup. Tell-tale signs of parameter sniffing or plan instability.
If any of these are missing, ask — don't invent them.
Step 2: The 6-Step Workflow
1. Reproduce on real data — tiny dev sets always pick the wrong plan.
2. Capture the plan — EXPLAIN (ANALYZE, BUFFERS) in PG;
EXPLAIN ANALYZE / FORMAT=JSON in MySQL 8+.
3. Diagnose the worst node — highest (actual_time × loops) or biggest
"Rows Removed by Filter" wins.
4. Hypothesize one fix — missing index? wrong order? stale stats?
N+1? subquery that should be a join?
5. Fix ONE thing, re-EXPLAIN — never two changes at once.
6. Re-measure hot AND cold cache — hot run hides disk cost; do both.The order is non-negotiable. Skipping step 1 wastes everyone's afternoon. Skipping step 6 ships a "fix" that only works while pages are still in RAM.
Step 3: Reading a Plan — the Glossary
Every PostgreSQL and MySQL plan is built from a small set of operators. Knowing what each one means — and when it's a red flag — is 80% of the skill.
| Node | What it does | Good when… | Red flag when… |
| Seq Scan | Full table scan | Table is small (<10k rows) or you genuinely need most rows | Big table + selective filter (e.g. WHERE country='IN' on 100M rows) |
| Index Scan | Walks an index, then fetches the heap | Index is selective and you need columns not in the index | Filter is unselective — you read most of the index and most of the heap |
| Index Only Scan | Index alone satisfies the query (covering index + visibility map up to date) | Hot read paths with a covering / INCLUDE index | (Usually none — this is the target) |
| Bitmap Index Scan + Bitmap Heap Scan | Gather many index hits, then read heap in physical order | Medium-selectivity filters; multiple index OR combinations | "Lossy" recheck on huge result sets — sometimes worse than Seq Scan |
| Nested Loop | For each outer row, probe inner — O(outer × inner-probe-cost) | Outer is tiny AND inner has a usable index on the join key | Outer is huge OR inner has no index ⇒ effectively O(n²) |
| Hash Join | Build hash on smaller side, probe with bigger side | Big × big equi-join | Batches: > 1 ⇒ memory spill — bump work_mem |
| Merge Join | Both inputs sorted on the join key, scan in parallel | Inputs already sorted by index | Forces a Sort node that spills to disk |
| Sort | Order rows | Small in-memory sort | external merge Disk: 4123kB — bump work_mem or add an ordering index |
| Hash Aggregate | Group via hash | Distinct values fit in work_mem | Spills — same fix as Hash Join |
| Group Aggregate | Group via sorted scan | Input is already sorted | Forces a costly Sort node |
| Materialize | Spool inner side for re-use in Nested Loop | Tiny inner | Hides a big inner — usually means the plan picked wrong |
| CTE Scan | Reads a materialized CTE | You actually want to materialize | PG 12+ inlines CTEs unless MATERIALIZED; an unexpected CTE Scan can be a planner barrier |
Beyond the operators, two diagnostic rules matter more than any single node:
- Plan Rows vs Actual Rows mismatch ⇒ stale statistics. Look at every
node and compare the planner's
rows=estimate to the actual rows the node returned. A 10×, 100×, or 10000× miss is a flashing red light: the planner picked the wrong join order, the wrong join strategy, or the wrong index. Fix the stats first, then re-EXPLAIN — often the bad plan goes away on its own. Rows Removed by Filteris the over-fetching signal. If a node returned 50 rows butRows Removed by Filter: 1,200,000, the planner scanned 1.2M rows and threw 99.996% of them away. That's a missing index condition: the filter belongs inside the index, not on top of it.
Step 4: Indexes — the 90% Lever
Most "slow query" tickets are fixed by adding (or fixing the order of) a single index. Index design is its own discipline; here are the rules that matter for tuning.
Index types
| Type | Use for | Notes |
| B-tree | Equality + range; the default; ASC/DESC orderings | 95% of indexes |
| Hash | Equality only | Rarely worth it — B-tree is almost as fast |
| GIN | Multi-value: JSONB, tsvector, arrays, pg_trgm | The fix for LIKE '%abc%' |
| GiST | Geometric, range types, legacy full-text | Niche |
| BRIN | Huge append-only tables physically sorted by a column (time-series) | Tiny index, fast on a range scan |
| Partial | CREATE INDEX … WHERE active = TRUE | Indexes only the hot subset; smaller + faster |
| Expression | CREATE INDEX … (lower(email)) | Required when the query wraps the column in a function |
The leftmost-prefix rule for composite indexes
For an index INDEX (a, b, c):
| Query predicate | Uses the index? |
WHERE a = ? | ✅ uses the a part |
WHERE a = ? AND b = ? | ✅ uses a, b |
WHERE a = ? AND b = ? AND c = ? | ✅ uses the full index |
WHERE a = ? AND c = ? | ⚠️ uses only a — c becomes a filter (skip-scan possible in some engines but don't rely on it) |
WHERE b = ? | ❌ does not use the index — no leftmost match |
WHERE b = ? AND c = ? | ❌ does not use the index |
ORDER BY a, b | ✅ index ordering can avoid a Sort |
ORDER BY b, a | ❌ wrong order — Sort is needed |
The practical heuristic for column order:
Equality columns first → range columns next → sort columns last.
So a query like WHERE country = ? AND created_at > ? ORDER BY created_at
DESC wants INDEX (country, created_at DESC) — not the other way around.
Covering / INCLUDE indexes
CREATE INDEX idx_users_country_created_at
ON users (country, created_at DESC)
INCLUDE (email);The INCLUDE columns aren't part of the key but are stored in the index
leaf. This lets the planner pick an Index-Only Scan — it never has to
touch the heap. For hot read paths returning a few columns, this is a
massive win.
Partial indexes
CREATE INDEX idx_orders_pending
ON orders (created_at)
WHERE status = 'pending';If 95% of orders are complete and you only ever query pending, a
partial index is smaller, hotter in cache, and faster than a full
index on (status, created_at).
Expression indexes
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- Query MUST match the expression:
SELECT * FROM users WHERE lower(email) = 'alice@example.com';A plain INDEX (email) is defeated by the lower() wrap in the
predicate — see pitfall #2.
The 12 indexing pitfalls
- Indexing low-cardinality columns alone —
INDEX (is_active)where only two values exist; the planner correctly ignores it. - Function-wrap defeats index —
WHERE upper(email) = ?defeatsINDEX (email). Fix with an expression index or stop wrapping. ORchains without a bitmap-or plan —WHERE a = ? OR b = ?can force a full scan. Often aUNION ALLof two indexed queries is faster.- Implicit type cast —
WHERE id = '42'whereidisbigint. The cast can block index use; pass the right type. - Wrong column order in composite — see the leftmost rule above. The single most common index bug.
- Over-indexing — every index slows every
INSERT/UPDATE/DELETEand consumes buffer cache. Drop the indexes nobody uses. - Extreme skew without extended stats —
country = 'US'matches 80% of rows; the planner needs to know. - Stale statistics —
ANALYZEthe table; in PG, raisedefault_statistics_targeton skewed columns. LIKE '%abc'on a B-tree — a trailing-wildcard pattern cannot use a normal B-tree; needspg_trgm+ GIN (PG) or a generated/reverse column.- Missing partial index on the hot subset — the 95/5 case above.
- Forgotten unused indexes — check
pg_stat_user_indexes.idx_scan = 0(or MySQL'ssys.schema_unused_indexes). They cost writes and cache forever. - Invalid after a failed
CONCURRENTLYbuild —pg_index.indisvalid = false. The index is on disk but the planner ignores it.DROP INDEX CONCURRENTLYand rebuild.
Step 5: Cardinality & Statistics
Bad estimates ⇒ wrong plans. Period. If the planner thinks a node will return 100 rows and it returns 10,000,000, it will pick Nested Loop and your query melts.
PostgreSQL
ANALYZE users; -- refresh per-column histograms
ALTER TABLE users ALTER COLUMN country
SET STATISTICS 1000; -- raise sample size for a skewed column
SET default_statistics_target = 500; -- session/global default; 100 → 500–1000 on skewed schemas
-- Extended statistics for correlated columns:
CREATE STATISTICS users_country_signup_stats (dependencies, ndistinct)
ON country, signup_source FROM users;
ANALYZE users;Autovacuum runs ANALYZE periodically, but only when row churn exceeds a
threshold — bulk loads and slow-trickle writes often leave stats stale.
MySQL 8+
ANALYZE TABLE users; -- refresh InnoDB stats
ANALYZE TABLE users UPDATE HISTOGRAM ON country WITH 64 BUCKETS; -- skewed-column histogramMySQL 8 introduced histograms — use them for skewed columns the optimizer otherwise misjudges.
Step 6: N+1 — the App-Layer Killer
The pattern: load 100 parents, then for each parent issue a child query — 101 round-trips instead of 1 or 2.
# Rails — N+1
users = User.where(country: "IN") # 1 query
users.each { |u| puts u.orders.count } # +100 queriesDetection
- Query log shows the same prepared statement repeated N times in fast succession (often microseconds apart).
- ORM debug log is the giveaway (
django.db.backends, RailsActiveRecordlogger, Hibernateshow_sql, Prismalog: ['query']). - PG:
pg_stat_statementsrow with very highcallsand lowmean_exec_timefor a query that fetches a parent's children. - MySQL: slow query log + APM trace.
- APM: Datadog / New Relic / Scout will flag it as an "N+1 query" span.
Fixes (pick the right one for the workload)
- Eager loading in ORMs — the right fix when the children are needed
for display:
- Rails:
User.where(country: "IN").includes(:orders) - Django:
User.objects.filter(country="IN").prefetch_related("orders")(orselect_relatedfor forward FK / one-to-one). - Hibernate:
LEFT JOIN FETCHin JPQL/HQL. - EF Core:
db.Users.Include(u => u.Orders). - SQLAlchemy:
selectinload(User.orders).
- Rails:
- Batch query —
SELECT * FROM orders WHERE user_id IN (?, ?, ?, …)then group in app code. - Single JOIN with grouped aggregate — when you only need a count or
sum, not the rows:
SELECT u.id, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE u.country = 'IN' GROUP BY u.id; - DataLoader-style batching in GraphQL — coalesces per-request field resolutions into a single batched query.
Step 7: Anti-patterns to Rewrite
| Anti-pattern | Fix |
SELECT * in a hot path | List the columns you actually need; lets covering indexes win |
OFFSET 100000 pagination | Keyset pagination: WHERE id > $last_seen_id ORDER BY id LIMIT 50 |
OR across columns | UNION ALL of two indexed queries is often a better plan |
Scalar subquery in SELECT (runs once per row) | JOIN LATERAL or a CTE that runs once |
NOT IN (subquery) with possible NULLs | NOT EXISTS — NOT IN returns no rows when the subquery returns even one NULL |
LIKE '%abc%' | pg_trgm + GIN index; or full-text search |
DISTINCT masking a missing join key | Find the duplicate-introducing join and fix it |
ORDER BY rand() / ORDER BY RANDOM() | TABLESAMPLE or keyset random; never sort the whole table |
Massive IN (…) lists (> ~1000 items) | Insert into a temp table and JOIN |
| Uncorrelated subquery that runs once | Lift to a JOIN — usually a free win |
Step 8: Caching & Memory Knobs
These are bigger levers than most micro-index tweaks and are routinely under-tuned.
PostgreSQL (defaults are conservative for any modern server):
| Setting | Typical | What it does |
shared_buffers | ~25% of RAM | PG's own page cache |
effective_cache_size | ~60–75% of RAM | Planner's estimate of total cache (PG + OS) — affects index vs seq choice |
work_mem | 4 MB default ⇒ raise per workload | Memory per Sort / Hash node, per parallel worker. Too small ⇒ Disk spill. Too big × many concurrent queries ⇒ OOM |
maintenance_work_mem | 256 MB – 1 GB | Used for CREATE INDEX, VACUUM, etc. |
random_page_cost | 1.1 on SSDs (default 4.0 assumes spinning disks) | Lower value makes the planner prefer index over seq |
MySQL (InnoDB):
| Setting | Typical | What it does |
innodb_buffer_pool_size | 50–75% of RAM | InnoDB's page cache; the single biggest knob |
innodb_io_capacity | match your disk | Affects flush + LRU pacing |
tmp_table_size / max_heap_table_size | bump for big GROUP BY | Avoid in-memory → disk tmp table spillover |
These move scans from disk to RAM — the difference between 50 ms and 50 µs per page is the difference between "slow query" and "fast query."
Step 9: Modern (2025) Features Worth Knowing
PostgreSQL
- JIT compilation (
jit = on, default since PG 12) — speeds up big-cost analytical plans by JIT-compiling expression evaluators. Adds startup latency on small queries — disable for OLTP if it shows up as overhead. - Parallel query — sequential scans, hash joins, hash aggregates,
and many index scans can run with multiple workers. Tune
max_parallel_workers_per_gather. - Partitioning —
PARTITION BY RANGE / LIST / HASH. With partition pruning, queries hit only the relevant partitions; combined with partition-wise joins, very large tables become tractable.
MySQL 8
- Hash join for inner equi-joins (massive win vs. the old block-nested-loop on big × big joins). Outer joins use hash join from 8.0.20+ when supported.
- Invisible indexes —
ALTER TABLE t ALTER INDEX idx INVISIBLEto test removing an index safely (planner ignores it, but it stays on disk and is still maintained on writes; flip back instantly if a query regresses). - Histograms — see Step 5; required for skewed columns.
Both
- Prepared statements + bind variables — avoid re-planning the same query for every literal value, and protect against SQL injection. But: on parameter-sensitive queries this is the source of parameter sniffing / plan instability — same SQL, different plans across runs, because the cached plan was made for an unrepresentative bind value.
Step 10: Red-Flag Quick Scan
Run this checklist over any plan:
- Seq Scan on a big table (> 1M rows) with a selective filter.
- Nested Loop with > 100k outer rows and no inner index.
Rows Removed by Filter≫ rows returned (over-fetching).- Sort spilling —
external merge Disk: ...kB. Index Condis empty butFilteris heavy (wrong/missing index).- Plan rows ≪ actual rows (stale stats).
- N+1 pattern in app logs /
pg_stat_statements. OFFSETover 5-figure values.LIKE '%...'on a non-trigram column.- Hash Join with
Batches: > 1(memory spill). - Same query has very different plans across runs (parameter sniffing / plan instability).
- An invalid index (
pg_index.indisvalid = false).
If any box is ticked, name it in the diagnosis.
Step 11: Output Format — the Tuning Report
Return your review in this exact shape.
🟠 Verdict banner
One of:
- ✅ Well-optimized — no actionable wins; current plan is appropriate.
- 🟡 Caveats — minor tweaks; latency mostly limited by data volume.
- 🟠 High wins available — clear bottleneck(s) with a known fix.
- ⛔ Critical fix needed — query is broken in production (timeouts, locking, missing critical index on a hot path).
Followed by one sentence: "Verdict because …"
Plan summary table
| Node | Actual rows | Actual time (ms) | Loops | Flag |
| Seq Scan on users | 750,000 | 4,820 | 1 | 🚩 over-fetch |
| Hash Join | 50 | 5,100 | 1 | ℹ️ |
| Sort | 50 | 5,180 | 1 | 🚩 external merge Disk: 8 MB |
Worst node first. One row per non-trivial node.
Diagnosis
Two or three sentences. Root cause(s) only — not the fix yet.
Example: "The Seq Scan on
usersfilters 50M rows down to ~750k with a selectivecountry = 'IN'predicate; the planner has no index oncountryso it must scan the whole table. The downstream Sort then spills to disk becausework_memis the 4 MB default."
Fix plan — ordered
Numbered list, smallest-blast-radius first. Always include the exact DDL for index changes, the before/after SQL for query rewrites, and the config command for stats/memory changes.
-- 1. Add the missing predicate index (no table rewrite, online build):
CREATE INDEX CONCURRENTLY idx_users_country_created_at
ON users (country, created_at DESC);
-- 2. Bump work_mem for this session to keep the sort in RAM:
SET work_mem = '64MB';
-- 3. Refresh statistics on the join column:
ANALYZE users;For N+1 fixes, show the single resulting SQL and the ORM change next to each other:
# Before — 1 + N
users = User.objects.filter(country="IN")
for u in users:
u.orders.count()
# After — 1
users = (
User.objects
.filter(country="IN")
.annotate(order_count=Count("orders"))
)Re-measure protocol
The exact commands to confirm the fix:
-- PG: hot run after warm-up
EXPLAIN (ANALYZE, BUFFERS) <the query>;
-- Cold cache (Linux): sudo sysctl -w vm.drop_caches=3
-- then re-run EXPLAIN (ANALYZE, BUFFERS).State the target: "expect Seq Scan → Index Scan, sort in memory, total time < 50 ms."
Risk notes
- Index build time / locking.
CREATE INDEX CONCURRENTLYis online but takes ~2× as long, can fail and leave an invalid index, and won't finish if other long transactions block it. Plan a window. - Write amplification. Every new index slows every
INSERT/UPDATE/DELETEthat touches its keys. If the table is write-hot, measure both sides. - Parameter sniffing. A plan that's great for
country = 'US'(80% of rows) may be terrible forcountry = 'IN'(1.5%). Prepared statements + a non-representative first bind ⇒ stuck on the wrong plan. Watch for plan instability after the change. - DML EXPLAIN warning. ⚠️ Don't
EXPLAIN ANALYZEanUPDATE/DELETEagainst production without wrapping inBEGIN; ... ROLLBACK;— it actually performs the write.
Disclaimer footer
I cannot run this query against your live database. Plans are data-and-config-dependent — re-run
EXPLAIN (ANALYZE, BUFFERS)on prod-sized data, on both hot and cold caches, before declaring victory. Index changes slow writes; verify the win.
Severity Model
Mirror the verdict banner:
- ⛔ Critical — hot-path query returning > 10× the rows it needs, full
scan on a > 10M-row table on a request path, long-running write
blocking reads, or a data-corruption / lost-update risk (e.g.
OFFSETover an evolving result set). - 🟠 High — missing index that would convert Seq → Index on a hot path; an N+1 in a production endpoint; a bad cardinality estimate driving wrong join order.
- 🟡 Medium — over-indexing on a write-heavy table, Sort spilling to
disk on an analytical query,
SELECT *in a hot path. - 🟢 Low — style / maintainability (
COUNT(*)vsCOUNT(1)— both are equivalent in modern engines). - ℹ️ Info — future-proofing notes (e.g. "partitioning is worth considering once this table crosses ~500M rows").
Cross-links
This skill pairs with:
- code-review-skill — for the broader correctness/security pass on the surrounding code; SQL is one layer.
- api-security-review — when the slow query is on an API hot path and you also want to scope authz / rate limit / cost-budget risks.
Educational engineering guidance, not a substitute for production APM, slow-query monitoring, or a qualified DBA on critical systems. Not affiliated with or endorsed by Anthropic.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

