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_relatedandprefetch_related, SQLAlchemyselectinload, Prismainclude), or one batchedWHERE 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 100Related
- foreign-key: the relationship being loaded.
- query-plan: compare join and batched strategies.
- secondary-index: index the lookup column.
- prisma-client: relation loading in Prisma.
- postgres: query tuning.