Blog/The N+1 problem

The N+1 problem, and loading you can see

The N+1 problem keeps coming back to codebases that have already fixed it more than once. That is not a discipline failure. It is what happens when the default hides queries and the fix is something you have to remember.

December 16, 2025Hibernate4 min read

The problem that keeps coming back

Render fifty users with the name of each user's city. Two tables, one foreign key. The list query fetches the users, and then each read of user.city.name runs its own select. One query becomes fifty-one. Nothing in the code, the types, or the compiler warned you, because nothing is broken: the code is correct, and it does exactly what the framework's default asks, which is to load the city the moment you read it. You find out instead from the query log, or from production latency. You fix it with a JOIN FETCH or an entity graph, and those work. Then a few weeks later someone writes a new query for a new screen, forgets the fetch clause, and it is back. The team has fixed this exact problem before. They will fix it again.

Why it keeps returning

Because the fix lives in the wrong place. The mapping declares one loading behavior, individual queries override it per call site, and the compiler checks none of it. Every new query is a fresh chance to forget, and forgetting is silent. A default that hides queries, plus an override you have to remember, is a quiet machine for reintroducing the same problem.

Put the loading decision in the type

ST/ORM moves the loading decision out of the query and into the entity, as a type. It is the same move behind entities being plain data. For a single-valued relationship, a foreign key written as its plain entity is always loaded with the row, through a join: write val city: City and the city comes back with the user. There is no lazy variant of it, so there is nothing to forget, and every caller behaves the same way. When deferring the load is the right call, you say so in the type: write the field as val city: Ref<City> and ST/ORM reads only the foreign key, until you fetch the city on purpose. The choice between the two is visible to the compiler and to the reviewer. If a loop makes that fetch per row, the extra queries are right there in the source, where a reviewer can see them, instead of hidden inside a getter.