N+1 Query Problem — Core Concepts

What makes it N+1

The pattern is deceptively simple:

  1. Query the database for N items (1 query)
  2. For each of those N items, query the database for related data (N queries)

Total: N + 1 queries.

With 10 items, it’s 11 queries — barely noticeable. With 100 items, it’s 101 queries. With 1,000 items on a page listing orders and their products, it’s 1,001 queries. Each query incurs network round-trip time (typically 0.5–2ms on localhost, 5–15ms across a network), so 1,000 queries can add 1–15 seconds of pure overhead.

Why it happens silently

ORMs like SQLAlchemy and Django load related objects lazily by default. When you access order.customer, the ORM silently runs a query behind the scenes. Your code looks clean:

orders = Order.objects.all()  # 1 query
for order in orders:
    print(order.customer.name)  # 1 query per order

There’s no obvious loop of database calls in the code. The queries are hidden inside property access, making the problem invisible until you look at query logs or notice slow response times.

How to detect it

Django Debug Toolbar

The Django Debug Toolbar shows every query executed during a request, with timing. If you see 50 identical queries that differ only in the WHERE clause ID, you have an N+1 problem.

SQLAlchemy echo mode

engine = create_engine("postgresql://...", echo=True)

This logs every SQL statement to stdout. Watch for repeated patterns.

nplusone library

The nplusone package automatically detects N+1 queries in both Django and SQLAlchemy and raises warnings or exceptions:

# settings.py
INSTALLED_APPS = [..., 'nplusone.ext.django']
NPLUSONE_RAISE = True  # raise exception on N+1

How to fix it

select_related uses a SQL JOIN to fetch related objects in one query. Works for ForeignKey and OneToOneField:

# 1 query with JOIN instead of N+1
orders = Order.objects.select_related('customer').all()

prefetch_related runs a separate query for related objects and joins them in Python. Works for ManyToManyField and reverse ForeignKey:

# 2 queries: one for orders, one for all related items
orders = Order.objects.prefetch_related('items').all()

SQLAlchemy: loading strategies

joinedload — adds a JOIN to the query:

from sqlalchemy.orm import joinedload
stmt = select(Order).options(joinedload(Order.customer))

selectinload — runs an IN query for related objects:

from sqlalchemy.orm import selectinload
stmt = select(Order).options(selectinload(Order.items))

selectinload is usually the better default — it avoids the duplicate rows that JOINs produce with one-to-many relationships.

Key concepts

When N+1 is acceptable

Not every N+1 needs fixing. If you’re loading a single object and accessing one relationship, that’s 2 queries — perfectly fine. The problem is scale: loops over collections where each iteration triggers lazy loads.

Rules of thumb:

  • Single object detail pages: lazy loading is usually fine
  • List pages, API endpoints returning collections, batch jobs: always eager load

The “N+1+1” variant

Nested relationships compound the problem. Loading orders → customers → addresses creates three levels of lazy loading. With 100 orders across 50 customers, that’s potentially 1 + 100 + 50 = 151 queries. Eager loading all three levels collapses it to 3 queries.

Common misconception

“Using select_related everywhere prevents N+1 problems.”

Over-eager loading has its own cost. JOINing five tables when you only need data from two wastes database resources and network bandwidth. The fix isn’t “eager load everything” — it’s “eager load what this specific view needs.” Different views of the same model should use different loading strategies.

One thing to remember: N+1 is a symptom of lazy loading in loops. The cure is telling the ORM upfront what related data you need — through select_related, prefetch_related, selectinload, or joinedload — so it can fetch everything in as few queries as possible.

pythondatabasesperformance

See Also

  • Python Aioredis Understand Aioredis through a practical analogy so your Python decisions become faster and clearer.
  • Python Alembic Understand Alembic through a practical analogy so your Python decisions become faster and clearer.
  • Python Asyncpg Database asyncpg is the fastest way for Python to talk to PostgreSQL without making your program sit around waiting.
  • Python Asyncpg Understand Asyncpg through a practical analogy so your Python decisions become faster and clearer.
  • Python Cassandra Python Understand Cassandra Python through a practical analogy so your Python decisions become faster and clearer.