The N+1 query problem

SQL, indexing and PostgreSQL Junior Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Spot the bug

This endpoint is slow with many orders. Which line causes N+1?

  1. import psycopg
  2. from psycopg.rows import TupleRow
  3. ​
  4. ​
  5. def orders_with_items(
  6. conn: psycopg.Connection[TupleRow],
  7. ) -> dict[int, list[str]]:
  8. orders = conn.execute(
  9. "SELECT id FROM orders ORDER BY id LIMIT 500"
  10. ).fetchall()
  11. result: dict[int, list[str]] = {}
  12. for (order_id,) in orders:
  13. rows = conn.execute(
  14. "SELECT sku FROM items WHERE order_id = %s", (order_id,)
  15. ).fetchall()
  16. result[order_id] = [sku for (sku,) in rows]
  17. return result
Hint 1 of 1

Count the queries for 500 orders.

Choose an answer, then open Show answer below to check it.

Show answer
  • Answer: line 13 rows = conn.execute(

One query per order inside the loop: 1 + 500 round trips. Load all items at once with WHERE order_id = ANY(%s) and group them in Python.

30-second answer

One query fetches the parents and then a separate query runs for every parent to get its children, so 500 orders cost 501 round trips. Each query is fast, so it hides in development, but with 500 rows and 1 ms of network latency it adds half a second. Fix it by loading children for all parents in one query, with a JOIN or WHERE parent_id = ANY(%s), or with the ORM's eager loading.

Practise saying it · 30 s. No recording, just a timer

Explain N+1 and the fix.

Say your answer out loud, then check which of these points you covered:

  • One list query plus one per row
  • Latency adds up
  • Batch with JOIN or ANY
  • DataLoader in GraphQL
  • Query-count tests

Deep dive

Spot it in logs as the same query repeated with different ids, or with an APM trace showing hundreds of short spans.

GraphQL resolvers cause it naturally; the DataLoader pattern batches and caches per request.

A JOIN can multiply rows (parents × children); for two independent child lists, two batched queries are often better.

Add a test that counts queries for an endpoint so it can't regress.

Two queries instead of 501 Ran on Python 3.13.5 · mypy --strict
Python 3.13
from collections import defaultdict

import psycopg

with psycopg.connect(autocommit=True) as conn:
    conn.execute("CREATE TEMP TABLE orders (id int PRIMARY KEY)")
    conn.execute("CREATE TEMP TABLE items (order_id int, sku text)")
    conn.execute("INSERT INTO orders SELECT generate_series(1, 3)")
    conn.execute(
        "INSERT INTO items VALUES (1, 'a'), (1, 'b'), (3, 'c')"
    )

    ids = [
        r[0]
        for r in conn.execute(
            "SELECT id FROM orders ORDER BY id"
        ).fetchall()
    ]
    items: defaultdict[int, list[str]] = defaultdict(list)
    for order_id, sku in conn.execute(
        "SELECT order_id, sku FROM items"
        " WHERE order_id = ANY(%s) ORDER BY order_id, sku",
        (ids,),
    ):
        items[order_id].append(sku)
    print({i: items[i] for i in ids})

Common mistakes

  • Fixing it with a cache instead of batching.
  • Eager-loading everything on every endpoint, which over-fetches.
  • Assuming the ORM batches automatically.

Senior take & follow-ups

Explain the round-trip cost, show the batched query, and mention a query-count test to keep it fixed.

Follow-up questions

Think of your answer first, then open the hint.

  1. How does GraphQL avoid N+1?

    Show hint

    Batch loaders that collect keys during one tick and issue one query.

  2. JOIN or two queries?

    Show hint

    JOIN for one-to-one or small one-to-many; separate batched queries for several collections.

Suggest a fix if something here is wrong or out of date.