An app feels instant on a laptop with 50 rows of seed data, then chokes when a production table hits tens of thousands. The culprit is often one simple pattern: the N+1 query. It slips through review because the code looks reasonable — and only bites as n grows.
What actually happens
The N+1 pattern appears when you fetch a list (1 query), then for each item fetch related data with one more query (N queries). Ten items = 11 queries; ten thousand items = 10,001 queries. On small data it's invisible; on real data it blows up linearly.
What makes it dangerous: the code is often written in a loop that looks clean, or hidden behind ORM lazy-loading. Nothing is 'wrong' syntactically — what's wrong is the number of round-trips to the database.
The fix: bound the round-trips, don't prettify the loop
- Fetch related data in one query with a JOIN, or one IN query for all ids at once (two queries total, not N+1).
- For many-to-many relationships, fetch in batches then map in memory (O(n)) instead of a query per item.
- Make sure the filtered/joined columns have the right index — a composite matching the query order.
- Measure by counting actual queries (logs/tracing), not by guessing. Queries-per-request is an honest metric.
Test at a realistic n
The root of the problem is often not the code but the data: tests and demos use an n too small to surface it. Test data-heavy paths at a size near production, and performance regressions get caught while they are still cheap to fix.
Rule of thumb: before writing a loop that touches the database, ask how many queries it will produce when n is large. If the answer grows with n, something needs batching.
Summary
N+1 is not an exotic bug but a default pattern that is easy to write unknowingly. Recognize its shape, bound round-trips with JOIN/IN plus the right index, and test at a realistic n. The difference between instant and timeout is often just the query count.