Lazy loading fetches a related record only at the moment your code actually accesses it; eager loading fetches related records upfront, alongside the rows that reference them. Both are valid strategies, and the choice is not "lazy is bad, eager is good" — it is about matching the strategy to what a specific piece of code actually does with the data. The trouble is that every major ORM defaults to lazy loading for individual relationships, and looping over a list of rows while lazily accessing each one's relationship issues one query per row. That pattern, known as the N+1 query problem, is the single most common ORM-caused performance bug in production applications, and it is entirely avoidable once you know where to look for it.
What changed in 2026
- Prisma's relation-loading strategy matured, with
relationJoins now stable and the query engine defaulting to a single SQL join for most include calls instead of separate round-trip queries.
- Drizzle's relational query API made eager loading a first-class, type-safe query builder feature rather than an ORM-specific afterthought bolted onto raw SQL.
- SQLAlchemy 2.0-style loading strategies became the norm across the Python ecosystem, with
selectinload largely replacing older, chattier eager-loading patterns for collections.
- Automatic N+1 detection shipped in more tooling — several APM vendors and ORM-adjacent libraries now flag suspiciously repetitive query patterns at runtime instead of leaving it to manual profiling.
How the two strategies actually execute
Lazy loading defers the query: you fetch a list of orders, and only when your code reads order.customer does the ORM issue a fresh query for that one customer. Do that inside a loop over 200 orders and you get 1 query for the orders plus 200 more for their customers — 201 total for data that could have been fetched in 2 queries. Eager loading fetches the relationship at the same time as the parent rows, either through a SQL JOIN (one query, wider rows) or a second batched query using WHERE id IN (...) across all the parent IDs at once (two queries, no duplication). Both eager strategies scale with the number of distinct queries, not the number of rows, which is what makes them immune to the N+1 pattern.
Eager loading syntax across common ORMs
| ORM |
Eager load call |
Strategy under the hood |
| Prisma |
include: { customer: true } |
Join or batched query, engine-dependent |
| Django ORM |
select_related() / prefetch_related() |
JOIN for to-one, batched IN query for to-many |
| SQLAlchemy |
joinedload() / selectinload() |
JOIN or batched IN query, chosen per relationship |
| TypeORM |
relations: { customer: true } |
JOIN by default |
| Drizzle |
with: { customer: true } |
Batched relational query |
The to-one versus to-many distinction matters: a JOIN for a to-many relationship duplicates the parent row once per child, which can be worse than a second batched query once the child count grows past a handful of rows.
How to choose per query
- Default to lazy loading for relationships you rarely access. Paying the eager-loading cost on every query for a relationship you touch 1% of the time wastes more than it saves.
- Eager-load anything you access inside a loop over rows. If the code pattern is "for each row, read a relationship," that is exactly the shape that turns into N+1 under lazy loading.
- Prefer a batched IN query over a JOIN for to-many relationships once the child count is more than a handful, to avoid row duplication in the result set.
- Measure with your ORM's query log or an APM tool, not intuition — count actual queries per request, not queries per line of code.
- Re-check after refactors. Adding a new loop over an existing lazy relationship is the most common way N+1 problems reappear months after the code was first written cleanly.
Common mistakes
Looping over a relationship without checking how it was loaded. The code reads perfectly fine; the query count only shows up in a slow query log or an APM trace, often in production, well after code review.
Eager-loading every relationship on every query. Blanket include statements fetch data the request never uses, bloating response size and sometimes running slower than the N+1 pattern they were meant to avoid.
Mixing JOIN-based eager loading with a to-many relationship that has many children. A user with 500 orders eagerly joined produces 500 duplicated copies of that user's row in the raw result set before the ORM deduplicates it back in memory.
Assuming eager loading is free. It trades query count for payload size and, in the join case, for duplicated data transferred over the wire — worth it for the common loop case, wasteful for data you access conditionally.
FAQ
How do I detect an N+1 problem in an existing codebase?
Turn on your ORM's SQL query logging in a staging environment, hit the suspect endpoint once, and count queries. A page that issues one query per item in a list, scaling with list length, is the signature.
Is eager loading always faster than lazy loading?
Not for every case — for relationships accessed rarely or conditionally, lazy loading avoids fetching data the request never needs. Eager loading wins specifically when you know you will access the relationship for every row.
Do GraphQL resolvers have this same problem?
Yes, arguably worse, because each field can resolve independently per row. The standard fix is a request-scoped batching layer, commonly a DataLoader, which coalesces per-row lookups into one batched query per tick.
Can I mix lazy and eager loading in the same query?
Yes — most ORMs let you eager-load specific relationships while leaving others lazy on the same query, which is exactly how you should be using them: eager for what the loop needs, lazy for everything else.
Where to go next
See how PgBouncer pools the connections your ORM opens, review database migration strategies for 2026 for keeping schema changes compatible with whichever loading strategy your ORM uses, and check how to pick a database in 2026 if the underlying data model is still an open question.