N+1 Query Hunter
N+1 Query Hunter
Find ORM N+1 patterns from captured query logs and replace them with batched eager loading, leaving cheap bounded ones alone. An N+1 is one query to fetch a list, then one more query per row to fetch its association - the most common ORM performance bug, hidden because each query is individually fast.
Do NOT use when reading or interpreting a single statement's EXPLAIN/EXPLAIN ANALYZE plan - use explain-plan-reader instead. Do NOT use when the fix is restructuring one query's SQL (rewriting joins, subqueries, or window functions) - use query-rewriter instead. This skill owns the loop-of-queries case where the fix is preloading, not rewriting one statement.
Workflow
- Capture the evidence before guessing. Turn on query logging and count the SELECTs for the slow request. ActiveRecord: tail the log or add the
bulletgem. SQLAlchemy:create_engine(..., echo='debug'). Prisma: enable thequerylog event. Hibernate:hibernate.show_sql=trueandgenerate_statistics=true. The tell is a burst of near-identical SELECTs differing only in theWHERE id = ?value, scaling with collection size - 20+ of them in one request is a near-certain N+1. Confirm server-side withpg_stat_statements: a normalized query whosecallscount is orders of magnitude above the endpoint's request rate. - Confirm it scales with input, and that it's worth fixing. Note how N grows: with collection size, page size, or user-controlled input. A useful bar: fix it when the request issues 10+ queries that grow linearly with rows, or when the loop's aggregate time is 20%+ of request latency - each query may cost only 0.5-1ms, but 200 of them adds 100-200ms plus per-query round-trip overhead. A 2-query "N+1" or one bounded to a handful of rows is not the target - move on.
- Replace lazy per-row loads with one batched preload. ActiveRecord:
Post.includes(:author, comments: :user)-preloadforces separate batched queries,eager_loadforces a LEFT JOIN,includeslets Rails choose. SQLAlchemy:selectinload(Post.comments)(second IN query, best default for collections) orjoinedloadfor one-to-one. Prisma: passinclude/selectwith nested relations in one call. Hibernate:JOIN FETCHin JPQL or an@EntityGraph; never rely on globalFetchType.EAGER. If a batched IN list can exceed roughly 1,000 ids, chunk it - huge IN lists degrade planning and can hit protocol parameter limits. - Avoid the fan-out trap.
joinedload/eager_loadon a one-to-many multiplies rows (cartesian fan-out) and can be slower than the N+1 it replaced - preferselectinload/preloadfor collections. Eager loading inside a method that is itself called in a loop just moves the N+1 up one level; hoist the preload to the outermost collection. - Use counts, not rows, for sizes. If the code loads children only to call
.size/.count, usecounter_cacheor a groupedCOUNTinstead of materializing the association. - Re-measure. Re-run the request with logging on and confirm the SELECT count dropped to a constant, and that p95 latency improved on the hot path.
Deliverable
Produce an N+1 fix report containing: the before evidence (query count and latency for the request, or the pg_stat_statements calls count), each offending call site with the preload change applied, the after query count (a constant, independent of collection size), the before/after p95 on the hot path, and a short list of N+1s deliberately left alone with the reason (bounded, cached, or cold path).