What is the N+1 query problem and how do you fix it?
Take a moment.
Say your answer out loud or write it down first — retrieval is what makes it stick. Reveal when you're ready.
Short answer
N+1 happens when code runs 1 query for a list plus N extra queries — one per item — to fetch related rows (classic ORM lazy loading). It turns one fast page into hundreds of round trips. Fix by eager-loading the relation in a single JOIN or batched IN query (includes/preload/select_related) or projecting exactly what you need.
Detailed explanation
Typical shape: SELECT * FROM orders (1 query returning 100 rows), then inside a loop, SELECT * FROM customers WHERE id = ? executed 100 times — 101 round trips where 1–2 suffice. Latency multiplies: each trip pays network + parse + plan overhead, so a 5ms query becomes a 500ms page, and it degrades linearly as lists grow. ORMs make it invisible because accessing order.customer lazily fires SQL from innocent-looking property reads. Detection: query logging in development (Rails Bullet, Django django-debug-toolbar / n+1 sniffers, or pg_stat_statements showing the same statement with different ids), and tests asserting query counts. Fixes: eager load with a JOIN (or two queries: one for parents, one WHERE parent_id IN (…) for children — often faster than a JOIN fan-out); select only needed columns instead of full models; add DataLoader-style batching in GraphQL resolvers so per-field resolvers collapse into one batched query; cache genuinely static relations. Counterpoint: eager loading everything always is also wrong — a giant JOIN fetching columns no one renders wastes memory and bandwidth. Load what the view needs, verify query counts in tests, and keep an eye on list sizes (paginate) so even the fixed version stays bounded.
Example
-- N+1: 1 + 100 queries (ORM lazy default)
-- SELECT * FROM orders LIMIT 100; -- 1 query
-- SELECT * FROM customers WHERE id = 7; ... -- ×100 in a loop
-- Fix: one JOIN (or one batched IN query)
SELECT o.id, o.total_cents, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
LIMIT 100;
-- ORM equivalents: Rails Order.includes(:customer),
-- Django Order.objects.select_related('customer'),
-- SQLAlchemy joinedload(Order.customer)Interview tip
Quantify it — '101 round trips instead of 1' — then name the ORM loader for your stack (includes, select_related, DataLoader). Mentioning a query-count test shows you prevent regressions, not just fix incidents.
Common mistakes
- Eager-loading every association unconditionally, trading N+1 for massive over-fetching.
- Missing N+1 inside GraphQL resolvers where each field hides a query.
- Fixing it only in development volumes while production lists are 100x larger.