Overview

An N+1 query runs one query for a list of N rows and then one query per row for related data: a page showing 100 orders issues 101 queries. It hides behind ORM lazy loading and GraphQL resolvers. Detect it by counting queries per request in development, using query logs or pg_stat_statements call counts, and fix it before production data sizes expose it.

Rules

  • Load related rows in bulk: a join, the ORM’s eager loading (Django select_related and prefetch_related, SQLAlchemy selectinload, Prisma include), or one batched WHERE id = ANY(...) query.
  • In GraphQL, batch per request with a DataLoader.
  • A join across a one-to-many relation multiplies rows and can cost more than a second batched query. Compare plans with EXPLAIN (query-plan).
  • Index the foreign-key column used for the lookup (foreign-key).
SELECT id FROM orders LIMIT 100;
SELECT * FROM order_items WHERE order_id = $1;              -- N+1: repeated 100 times
SELECT * FROM order_items WHERE order_id = ANY($1::bigint[]); -- batched: one query for all 100