Every ORM, from ActiveRecord to Django's ORM to Hibernate to Prisma, produces the same bug in the same shape, because it's not a bug in any specific library — it's the natural consequence of letting object access patterns silently trigger database queries. You loop over a collection, touch a related object on each item, and each touch fires its own round trip. One query becomes N+1 queries, and it's invisible in code review because the code reads like ordinary object access, not like a loop full of database calls.
What it actually looks like
# Django, but structurally identical in every ORM
orders = Order.objects.filter(status='pending') # 1 query
for order in orders:
print(order.customer.name) # 1 query per order — N additional queriesThe code is correct and readable, which is exactly the problem — nothing about order.customer.name signals "this hits the database." At 10 orders this is invisible in local testing. At 10,000 orders in production, it's 10,001 round trips where one join would have worked, and the latency shows up as a page that mysteriously gets slower as a specific list grows, not as an obvious error. What actually hits the database is the query log made visible:
-- One query for the list
SELECT id, customer_id, status FROM orders WHERE status = 'pending';
-- Then one of these per row in the result set above
SELECT id, name FROM customers WHERE id = 1;
SELECT id, name FROM customers WHERE id = 2;
-- ...repeated once per order, N timesWhy it survives code review
The reason this pattern keeps shipping isn't carelessness, it's that the fix lives in a different layer than the bug. The loop looks fine on its own; the problem is only visible if you already know that .customer is a lazy-loaded relation, which requires either reading the ORM's query log or already being burned by this exact pattern once. Query logging in development (django-debug-toolbar, Rails' bullet gem, Hibernate's show_sql, or just turning on your ORM's SQL logging locally) is the single highest-leverage habit here, because it turns an invisible problem into an obvious one — a request log showing 200 nearly-identical queries is unmissable in a way the source code isn't.
The actual fix: eager loading
Every mainstream ORM has a mechanism to fetch the related data in a fixed number of additional queries — typically one extra query using WHERE id IN (...), or a single join — instead of one per row.
# Django: one extra query total, not one per order
orders = Order.objects.filter(status='pending').select_related('customer')# Rails: same idea, .includes issues a batched second query
Order.where(status: 'pending').includes(:customer).each do |order|
puts order.customer.name
endselect_related (and Rails' .includes in its join form) generates a single SQL join. Some ORMs also offer a batched-fetch mode — a separate WHERE id IN (...) query instead of a join — which is preferable when the relation is one-to-many and a join would multiply row counts unnecessarily; know which one your ORM's eager-loading call actually produces, because they have different performance characteristics on large relations.
When eager loading isn't enough
Eager loading everything by default has its own cost — fetching relations you don't actually render on every request adds real overhead. The pattern that scales is loading relations deliberately per code path: list views load only the fields the list actually shows, detail views eager-load what the detail page needs, and nothing is fetched by default just because the model has a relation defined. This also means N+1 bugs tend to reappear whenever a template or serializer is extended to show one more related field, so the query log check belongs in the review process for any change that touches a loop over a collection, not just as a one-time audit.
Catching it before production
The durable fix isn't vigilance, it's automation: tools like Rails' bullet gem or Django's select_related-detection linters raise the alarm in development or CI the moment a lazy load happens inside a loop, which catches the pattern at the point it's introduced instead of after it's degraded a production page for months.