Find N+1 Queries
Scan code read-only for N+1 query patterns — loops that query per iteration and handlers that fan out per-row — and report each with a location, why it is N+1, and the concrete eager-load/batch/set-based fix.
/find-n-plus-one<path or area to scan (optional)>npx agentscamp add commands/find-n-plus-oneInstall to ~/.claude/commands/find-n-plus-one.md
A read-only slash command that detects N+1 query patterns: it finds loops/maps that query (or trigger a lazy-load) per iteration and handlers that fan out per-row queries, then reports each finding worst-first with its location, why it is N+1, a before/after sketch, the concrete fix (eager load, join, batch, DataLoader, or one set-based query), and how to confirm it via query logging or EXPLAIN.
Scope
Treat $ARGUMENTS as the path or area to scan — a directory, a file, a feature ("the orders list endpoint"), or a layer ("the serializers"). Restate the scope in one sentence before scanning.
If $ARGUMENTS is empty, scan the data-access surface of the repo: ORM models/repositories, serializers/resolvers, and request handlers. Say which paths you chose so the user can narrow it.
WARNING
Read-only mode. Do not edit, run migrations, or execute queries. Your only output is the prioritized findings report. The Bash/Edit tools are deliberately not granted — if a fix needs verifying, tell the user the exact command to run, don't run it.
Step 1 — Identify the data-access vocabulary
Before grepping for loops, learn how this codebase talks to the database, because the lazy-load that triggers the extra query is often invisible. Grep for the ORM/query primitives in use:
- ActiveRecord (Rails):
.where,.find,.find_by, association accessors inside.each/.map; missingincludes/preload/eager_load. - Django:
.objects.,.filter, related-field access in a loop withoutselect_related/prefetch_related. - SQLAlchemy:
session.query/select, relationship access with defaultlazy="select"; missingselectinload/joinedload. - Prisma/TypeORM/Sequelize:
findMany/findOne/findByPkinsidemap/for; missinginclude/relations/eager. - Raw SQL / micro-ORMs: a
SELECT … WHERE id = ?helper called inside a loop.
Note which one is in play; the recommended fix differs per ORM.
Step 2 — Find queries issued per iteration
Grep for loop constructs (for, forEach, .map, .each, list/dict comprehensions, Promise.all([...].map(...))) and inspect the body of each for a data-access call from Step 1. Flag any case where the query depends on the loop variable (fetch(item.id), item.author.name) — that's the per-row query.
NOTE
The most dangerous N+1s are the invisible ones: a property access like order.customer.email that looks free but silently fires a lazy SELECT each time. Don't only grep for .query() — flag relationship/foreign-key attribute access inside any loop.
Step 3 — Find handlers that fan out per row
Trace request handlers / GraphQL resolvers / serializers that return a collection. A field resolver or a serializer method that loads a related record runs once per item in the response even when no explicit loop is visible in the handler. Flag list endpoints whose per-item shape includes a related lookup, and GraphQL resolvers without a batching layer.
Step 4 — Rank by blast radius
Order findings worst-first. Severity is roughly (how large N gets) x (how hot the path is):
- Critical: unbounded/paginated collections on a hot path (list endpoints, dashboards, exports) — N grows with data.
- High: loops over user-controlled or large fixed sets.
- Low: loops over a small bounded set (a 3-item enum) — note it, but don't alarm.
Step 5 — For each finding, prescribe the concrete fix
Per finding, give: the file:line, a one-line why it's N+1, and the fix matched to the ORM — not a generic "optimize this":
- Eager load / preload when you need the related rows:
includes/preload(Rails),select_related(1:1/FK) orprefetch_related(1:many) (Django),selectinload/joinedload(SQLAlchemy),include/relations(Prisma/TypeORM). - Single set-based query when you only need an aggregate or a filtered subset: replace the loop with one
WHERE id IN (...)/GROUP BY/JOINinstead of looping. - Batch / DataLoader for GraphQL resolvers or service boundaries where you can't restructure the caller — collect the keys, resolve them in one batched call per tick.
- Map in memory when the related set is small and reused: load once, index by key, look up in the loop.
Include a compact before/after sketch (4-8 lines) so the fix is unambiguous.
Step 6 — Tell them how to confirm
Close each finding with the verification step the user runs themselves (read-only command guidance only):
- Enable query logging for the path (Rails: watch the dev log /
ActiveRecord::Base.logger; Django:django.dblogger ordjango-debug-toolbar; SQLAlchemy:echo=True; Prisma:log: ['query']) and confirm the count drops from ~N to 1-2. - Or
EXPLAIN/EXPLAIN ANALYZEthe new set-based query to confirm it's a single plan, not a loop.
Report
Deliver a prioritized findings list (worst offenders first) as your message — it is the whole deliverable. For each: severity · file:line · why it's N+1 · the fix · before/after sketch · how to confirm. If you found nothing, say so plainly and name the paths you scanned. End with the single highest-leverage finding to fix first.
Frequently asked questions
- What is an N+1 query?
- An N+1 query is the antipattern where code runs one query to fetch N rows, then runs one additional query per row (N more) instead of fetching the related data in a single set-based query. The cost scales linearly with N, so a 1-row page is fast and a 1000-row page is 1000x slower.
- Does this command change my code?
- No. It is read-only — it scans with Read, Grep, and Glob and reports findings with before/after sketches. You (or a follow-up command) apply the fix, then confirm the query count dropped via query logging or EXPLAIN.
Related
- Profile Postgres QueriesProfile a Postgres workload to find the queries actually costing you — rank by total time with pg_stat_statements, EXPLAIN the worst offenders, and recommend the highest-leverage fix.
- Set Perf BudgetDefine and enforce a cost and latency budget for an LLM feature or endpoint — set p95/p99 latency and cost-per-request ceilings, instrument to measure them against real traffic, and wire a check that fails when the budget is breached.
- Find BugInvestigate a reported symptom, form hypotheses, and locate the root cause.
- Trace Data FlowTrace how a value, field, or variable flows through the codebase from source to sink.
- Add CachingAdd a caching layer to one expensive function or endpoint correctly — confirm it's cacheable, design the cache key/TTL/layer/invalidation, handle stampedes, wrap the call in one place, and report the design.