An ORM lets you write code that reads like objects and produces SQL you never see. That is the point, and it is why performance problems in ORM-backed applications are so consistently surprising — the expensive operation does not look expensive in the code.
Nearly all of these problems are visible immediately once you look at the generated SQL, which is why the single most valuable habit is logging it.
What changed in 2026
- Query logging in development became standard. Frameworks made it easier to see generated SQL, which surfaced problems earlier.
- N+1 detection tooling spread. Automated detection of repeated similar queries in a request became widely available.
- Explicit loading strategies gained favour. Configuring relationships as lazy by default and loading explicitly became the recommended pattern.
- Query counting in tests appeared. Asserting on the number of queries a code path executes became a practical regression guard.
The N+1 problem
The dominant issue, and it looks entirely reasonable in code.
Fetch a list of orders. Loop over them. Access each order's customer. Each access triggers a query, because the customer was not loaded. One hundred orders becomes one hundred and one queries.
In code that is a loop with a property access. In the database it is a hundred round trips, each with its own latency, which dominates everything.
The fix is loading the related data up front — a join or a second query fetching all the customers at once. Every ORM supports this and the default is usually not to do it, because the ORM cannot know whether you will access the relationship.
The reason it survives to production is that it is invisible at small scale. Ten orders in development feels fine; ten thousand in production does not — see GraphQL N+1 for the same problem at an API layer.
Over-fetching
| Pattern |
What it does |
| Load the entity to read one field |
Fetches every column |
| Load a collection to count it |
Materialises rows to count them |
| Load a collection to check emptiness |
Materialises rows to check existence |
| Load with all relationships eagerly |
Fetches far more than needed |
Selecting whole entities is the default, and it fetches every column including large text or JSON ones. On a table where large values are stored out of line, that means an additional fetch per row for data you are discarding — see TOAST storage.
Counting by loading is worse than it looks: the database can count without materialising rows, and loading a collection to call length on it transfers everything to count it.
Checking whether a collection is empty has the same shape, and existence checks are among the cheapest queries a database can run.
Transactions and connections
Two subtler problems.
Transaction scope wider than necessary. An ORM operation wrapped in a transaction that also performs an external API call holds a database transaction open across a network round trip. That blocks cleanup and holds locks for the duration — see MVCC explained.
Connection held across the request. Some configurations hold a connection for a whole request rather than for the queries within it, which multiplies connection demand — see connection limits.
Both are configuration and code-structure issues rather than ORM defects, and both are common.
Making problems visible
Log generated SQL in development. The single highest-value habit. Most of these problems are immediately obvious when you see twenty near-identical queries for one page load.
Count queries in tests. Asserting that a code path executes a bounded number of queries catches N+1 regressions before they ship, and it is one of the cheapest performance guards available.
Watch query statistics in production. A trivial query with an enormous call count is the signature — see pg_stat_statements.
Do not abandon the ORM for raw SQL by reflex. The ORM is not the problem for the overwhelming majority of queries. Use raw SQL for the few that genuinely need it, and keep the ORM for everything else.
Common mistakes
- Lazy loading in a loop. The classic N+1.
- Loading entities to read one field. Fetches everything.
- Counting by materialising. The database counts far better.
- Eager-loading everything to avoid N+1. Over-fetches instead.
- Never looking at generated SQL. All of this stays invisible.
- Wide transaction scope. Holds locks across slow operations.
- Rewriting everything in raw SQL. Loses the ORM's benefits for a few queries' sake.
FAQ
Should I use an ORM at all?
For most applications, yes — the productivity and safety benefits are real, and the problems here are avoidable once you know them. The alternative is writing and maintaining a great deal of SQL by hand.
How do I find N+1 in an existing codebase?
Log queries per request and look for repeated similar queries. Detection tooling automates this. Production query statistics also show it as a high call count on a trivial query.
Is eager loading always better?
No — it over-fetches when the relationship is not used. Load explicitly where you know you need it, rather than configuring everything eager.
What about raw SQL for reports?
Reasonable. Complex analytical queries are where ORMs struggle most, and dropping to SQL there while keeping the ORM elsewhere is a sensible split.
Where to go next
For the API-layer version of the same problem, read GraphQL N+1. For finding these in production, pg_stat_statements, and for the fetching cost of large columns, TOAST storage.