The N+1 With No Query In It: JPA Fetch Types From First Principles

An export endpoint died at thirty seconds. Not degraded, died: the platform router gave up, the browser reported a CORS failure that had nothing whatsoever to do with CORS, and the UI showed a toast saying something went wrong.

I had three hypotheses inside twenty minutes of reading code. All three were wrong.

Then I stopped reading and ran a count(*) on every table the export touches. Forty-three taxonomy categories. Three form models. Zero cost centres. Just under twenty thousand tax codes.

And this line, in a DTO factory three files away from anything that looks like persistence:

.subsidiaryNames(
    taxCode.getSubsidiaries().stream()
        .map(Subsidiary::getName)
        .collect(Collectors.toSet()))

No repository. No findAll. No annotation. Nothing a reviewer scanning a record’s static factory method would stop on. It’s about nineteen thousand round trips to the database.

This article is the reference piece I wish I’d read before that afternoon: what lazy loading actually is at the object level, why JPA’s per-association defaults are the opposite of what your intuition wants, why fetch = LAZY on a to-one association is sometimes quietly ignored, and why the fix for a bulk path is almost never the annotation everyone reaches for first.

The bug is the cold open. The mechanism is the article.

Everything here is Hibernate 6.6 on Spring Boot 3.5, which is what I run. Most of it has been true since Hibernate 4, and I’ll call out the bits that haven’t.


1. What is “lazy”, actually?

When Hibernate hydrates an entity from a result set, it has to put something in every field. For a lazy association, that something is a stand-in.

There are two different stand-ins, and conflating them is the source of a lot of confusion.

For a to-one association (@ManyToOne, @OneToOne), Hibernate puts a proxy in the field: a runtime-generated subclass of the target entity that holds the identifier and a reference to the session, and nothing else. Every method on it is intercepted. Call one, and the proxy runs a select, populates itself, and delegates. Call one after the session closed, and you get LazyInitializationException.

For a collection (@OneToMany, @ManyToMany, @ElementCollection), Hibernate puts a persistent collection wrapper in the field: PersistentBag for a List, PersistentSet for a Set, PersistentMap for a Map. These implement the JDK collection interfaces, which is why your field typed List<SubsidiaryLink> accepts one without complaint. Internally the wrapper starts uninitialised, holding a session reference and the owner’s key. Touch it and it runs its select.

So the field in your entity is never null and never “not there”. It’s always a real object that is lying to you about how much work it represents. That’s the whole trick, and it’s also why nothing at the call site can tell you what’s about to happen.

Here’s the shape from the bug, cleaned up:

public interface SubsidiaryLinked {

  List<SubsidiaryLink> getSubsidiaryLinks();

  default List<Subsidiary> getSubsidiaries() {
    return getSubsidiaryLinks().stream()
        .map(SubsidiaryLink::getSubsidiary)
        .toList();
  }
}

getSubsidiaries() is a default method on an interface. It contains a stream and a method reference. It’s the friendliest-looking code in the file. It’s also two nested lazy loads, and we’ll come back to exactly how many queries it costs.

2. What triggers initialisation, and what doesn’t?

The rule for collections is blunt: any access initialises. size(), isEmpty(), iterator(), stream(), contains(). There’s no read that’s free.

The rule for to-one proxies is more interesting, and it’s worth knowing precisely, because it’s the difference between a fast loop and a slow one.

Calling the identifier getter on a proxy does not initialise it. Hibernate already has the identifier, that’s what it built the proxy from, so link.getSubsidiary().getId() is a field read and nothing else. Calling literally any other getter does initialise it.

This shows up in real code more than you’d think. Take these two methods on the same interface:

default boolean hasSubsidiary(UUID subsidiaryId) {
  return getSubsidiaryLinks().stream()
      .anyMatch(link -> link.getSubsidiary().getId().equals(subsidiaryId));
}

default List<Subsidiary> getSubsidiaries() {
  return getSubsidiaryLinks().stream()
      .map(SubsidiaryLink::getSubsidiary)
      .toList();
}

They read identically. hasSubsidiary initialises the collection and then touches only identifiers, so it costs one query. getSubsidiaries returns proxies, and the caller inevitably calls getName() on them, so it costs one query plus one per distinct target entity. Same file, same style, wildly different cost, and the difference is which getter the caller happens to reach for.

If the association uses @MapsId (the identifier of the link entity embeds the foreign key), this is even sharper: the id is sitting in the embedded key, so the proxy is pure overhead until someone wants a real column.

Two more triggers people walk into:

toString(), equals(), and hashCode(). If you generate these over all fields, they walk every association. Lombok’s @Data and @ToString do exactly that by default. Any decent JPA codebase ends up with entities that look like this:

@ToString(callSuper = true, exclude = {"subsidiaryLinks"})
@EqualsAndHashCode(callSuper = true, exclude = {"subsidiaryLinks"})
public class TaxCode extends BaseEntity implements SubsidiaryLinked {

Those exclude lists aren’t style. They’re load-bearing. Drop them and a single log line at DEBUG becomes a table scan.

Your debugger. IntelliJ evaluating a variable in the watch window will happily initialise proxies while you’re stepping, which means the code you’re debugging behaves differently from the code that ran in production. If you’re counting queries, count them from logs, not from a breakpoint.

The programmatic escape hatches, for completeness: Hibernate.isInitialized(obj) tells you without triggering, and Hibernate.initialize(obj) triggers deliberately. Both are worth knowing about mostly so you can assert on them in tests.

3. What are the defaults, and who thought they were a good idea?

Here’s the table. It hasn’t changed since JPA 1.0:

AnnotationDefault fetchWhat it costs when it fires
@Basic (any plain column)EAGERnothing, it’s in the row
@ManyToOneEAGERa join, or a secondary select per row
@OneToOneEAGERa join, or a secondary select per row
@OneToManyLAZYone select per owning entity
@ManyToManyLAZYone select per owning entity
@ElementCollectionLAZYone select per owning entity

The pattern is clean: to-one is eager, to-many is lazy. The reasoning behind it is also clean, and it’s wrong.

The spec authors were thinking about a single row. A @ManyToOne resolves to at most one other row, and one extra row is cheap, so fetch it and save the developer a round trip. A @OneToMany resolves to an unbounded set of rows, which might be enormous, so don’t.

That’s a defensible judgement about one entity in isolation. It falls apart the moment entities compose. Eager to-one associations are transitive: load an Invoice eagerly fetching its Vendor, whose Currency and PaymentTerm are also eager, each of which pulls its own eager references, and a single find() becomes a nine-table join returning a cross product you never asked for. Nobody designed that. It’s just what happens when a per-field default gets applied to a graph.

There’s a second asymmetry in that table that matters more than the first, and it’s about failure mode rather than cost.

The eager default fails loudly. Your queries get fat, your logs get ugly, somebody notices the join. The lazy default fails silently, later, in a completely different file, at a size that depends on production data you don’t have locally. Which is the one that took my endpoint down.

4. Why is fetch = LAZY on a to-one sometimes ignored?

This is the part most articles skip, and it’s the reason a lot of people believe lazy loading “doesn’t work” on to-one associations.

Hibernate can only be lazy about something if it can defer the query without changing observable behaviour. For a to-one association, that means it needs to be able to hand you a proxy without knowing whether the target exists.

Two things can block that.

The target class can’t be proxied. The generated proxy is a subclass, so the entity class can’t be final, and it needs an accessible no-arg constructor. Mark an entity final and Hibernate silently loads it eagerly, because it has no other option.

Hibernate can’t know whether the value is null. This is the big one, and it splits by association type.

For @ManyToOne, the foreign key lives in the row you just loaded. Hibernate reads the FK column, and if it’s null the field is null, otherwise it builds a proxy for that id. It never needs a query to decide. So @ManyToOne(fetch = LAZY) works, reliably, and you should be using it.

For @OneToOne, it depends on which side you’re on. On the owning side the FK is in your row, same story as above, with one wrinkle: if the association is optional = true, Hibernate still has to distinguish “FK points at a row” from “FK points at nothing”, and depending on mapping it may issue a query to find out, which defeats the point. On the inverse side (mappedBy), there’s no FK in your row at all. Hibernate literally cannot know whether a matching row exists without going to look. So it goes to look, and your LAZY is decorative.

The fix for both is to tell Hibernate that null is impossible:

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@MapsId("subsidiaryId")
@JoinColumn(name = "subsidiary_id", nullable = false)
private Subsidiary subsidiary;

optional = false is the load-bearing word there. It’s a promise that the association always resolves, which is exactly the information Hibernate needs to build a proxy without checking. If you can’t make that promise, the other route is compile-time bytecode enhancement (hibernate-enhance-maven-plugin with enableLazyInitialization), which rewrites field access so Hibernate can intercept it directly and stop needing proxies at all. It works, and it’s more machinery than most teams want to own.

Worth being honest about the ordering here: bytecode enhancement is the general answer, optional = false is the answer you’ll actually use.

5. So should I just make everything EAGER?

No, and the reason isn’t “eager is slow”. It’s that eager is a decision made in the wrong place.

LAZY and EAGER are not symmetric options. LAZY is a default that any individual query can override. EAGER is a mandate that no query can escape.

If an association is lazy, the export path can join-fetch it, the detail screen can use an entity graph, and the list screen can skip it. Three call sites, three appropriate strategies, one mapping. If it’s eager, every query in the entire application that touches that entity pays for it, forever, including the ones that only wanted the name and the id. There’s no per-query opt-out, because the JPA spec doesn’t define one. Hibernate has FetchMode and some query-level tricks, but you’re fighting the mapping rather than using it.

There’s also a trap inside eager that surprises people who assume it means “joined”.

EntityManager.find() builds a fetch plan and does turn eager to-one associations into joins. A JPQL query does not. When you write select t from TaxCode t, Hibernate runs that query as written, gets your entities back, notices they have eager associations that aren’t populated, and then goes and fetches them. One extra select per row.

That’s an N+1. From EAGER. On the exact same mapping that people set to EAGER in order to avoid an N+1.

The rule that falls out of all this, and the one I’d defend in any code review: lazy on every association, eagerness declared per query. It’s more typing at the call site and it’s the only version that scales, because it puts the decision next to the use case that has to justify it.

6. The second invisibility: the call site

Section 3 covered the annotation you didn’t write. This is the one you did write, and it’s worse, because it looks like domain code.

Go back to the bug:

@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "linked_entity_id", referencedColumnName = "id")
@SQLRestriction("linked_entity_type = 'TAX_CODE'")
private List<SubsidiaryLink> subsidiaryLinks = new ArrayList<>();

Read that as a reviewer. Cascade, orphan removal, a join column, a discriminator filter. It’s a paragraph about ownership semantics: these links belong to this tax code, they die with it, here’s how they’re found. Every word of it is about lifecycle.

There’s no fetch attribute. The fetch strategy of this association is declared by its absence, and the declaration is invisible precisely because it’s an omission. You can’t review a token that isn’t in the file.

Then, three files away:

public static TaxCodeExportPayload fromTaxCode(TaxCode taxCode) {
  return builder()
      .name(taxCode.getName())
      .rate(taxCode.getRate())
      .active(taxCode.isActive())
      .subsidiaryNames(
          taxCode.getSubsidiaries().stream()
              .map(Subsidiary::getName)
              .collect(Collectors.toSet()))
      .build();
}

Four plain getters and one that isn’t. getName(), getRate(), isActive() are field reads. getSubsidiaries() is a query, then a second query per distinct target, wrapped in a default interface method so it doesn’t even live in this class or the entity class. There is no syntactic difference between the cheap calls and the expensive one. None. The language gives you nothing to see here.

Now the arithmetic, called from a loop over every tax code in a tenant:

  • Just under 20,000 collection initialisations, one per tax code. That’s the N+1.
  • Close to 900,000 link rows materialised into one persistence context, because these tenants link roughly 95 subsidiaries per tax code.
  • And then, pleasantly, only about 95 more queries for the Subsidiary proxies, not 900,000, because the persistence context is an identity map and the second tax code’s subsidiaries are already loaded.

That last point is the one bit of good news in the whole mechanism, and it’s worth internalising: the first-level cache bounds the inner loop of a nested lazy load at the number of distinct targets. It does nothing at all for the outer loop.

Two more things conspire to keep this quiet.

The whole export runs inside one @Transactional method, so the session is open the entire time, so nothing ever throws LazyInitializationException. That exception has a bad reputation and it doesn’t deserve it. It’s the only mechanism in the entire stack that tells you a fetch boundary exists. Code that never sees it isn’t code without lazy loading problems, it’s code where the problems are silent.

And Spring Boot keeps the session open past the service layer anyway. spring.jpa.open-in-view defaults to true, which means the persistence context lives until the response is rendered, which means lazy loads in your serialisation layer also succeed quietly. Boot logs a warning about this at startup that essentially everyone has learned to scroll past. Set it to false, take the LazyInitializationExceptions that follow, and fix each one properly. It’s a rough afternoon and a permanently better codebase.

Oh, and the interface was implemented by eight entities. Fixing the tax code path fixes one of eight identical landmines. Shared abstractions distribute their bugs with the same efficiency they distribute their benefits, which is the sort of thing that reads as obvious written down and doesn’t occur to anyone at review time.

7. How do I see this before production does?

The mechanism is invisible in source, so stop trying to catch it in source. Make the machine count.

Turn on statistics in dev.

spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=DEBUG

You get a summary line per session with the query count in it. Once you’ve seen 19,114 queries printed after a request you had assumed did about six, you never fully trust a getter again.

Log the SQL itself when you’re hunting.

logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

That second logger is a Hibernate 6 rename. If you’ve been copying org.hibernate.type.descriptor.sql.BasicBinder out of an old blog post and wondering why the parameter values stopped appearing, that’s why.

Assert query counts in tests. This is the one that actually holds the line, because it fails in CI instead of in a support ticket:

var stats = entityManagerFactory
    .unwrap(SessionFactory.class)
    .getStatistics();
stats.clear();

exportService.exportConfig(tenantId);

assertThat(stats.getPrepareStatementCount()).isLessThan(50);

Seed the fixture with enough rows that an N+1 has room to show itself. Ten parent rows is plenty: the assertion catches the shape, not the volume. A test that passes with one parent row proves nothing at all.

And a review heuristic, since reviews are where this gets caught cheaply: any method that returns entities derived from an association is a query, whatever it’s named and wherever it lives. Treat DTO factories, mappers, and default methods on entity interfaces as query sites. If a static fromEntity method touches anything that isn’t a column, it needs its inputs handed to it, not fetched from inside.

8. Fixing it, from cheapest to correct

Four rungs. They’re all legitimate, they solve different problems, and reaching for the wrong one is how you end up shipping something that works in staging.

Rung one: batch the fetches.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

Or per association with @BatchSize(size = 100). Hibernate stops resolving one pending proxy at a time and instead grabs up to a hundred per round trip with an in (...). Nineteen thousand queries become about a hundred and ninety.

This is a genuinely good global default and I’d argue for setting it in any Spring Boot app that talks to a real database, because it makes accidental N+1s an order of magnitude less fatal. It’s off by default in Hibernate 6 (-1), and Spring Boot doesn’t set it for you.

What it doesn’t do is reduce the data. You still materialise close to 900,000 link rows and 20,000 managed entities into one persistence context. The endpoint gets faster and the memory profile doesn’t move.

Rung two: fetch it in the query.

@EntityGraph(attributePaths = "subsidiaryLinks")
List<TaxCode> findAllByTenantId(UUID tenantId);

Or join fetch in JPQL. Precise, per-use-case, exactly the “eagerness declared per query” rule from section 5. For a detail screen or a small aggregate, this is the right answer and you should stop reading here.

For a bulk path it has two sharp edges. Join-fetching two List-typed collections in one query throws MultipleBagFetchException, and the standard workaround (type them as Set) changes your entity’s semantics to fix a query. And combining a collection fetch with pagination makes Hibernate fetch the entire result set and paginate it in memory, with a warning in the logs that is very easy to miss.

Rung three: collect the ids, fetch in one in clause. The obvious next move, and worth understanding why it’s not the last one.

At twenty thousand ids you’re building a statement with twenty thousand bind parameters. PostgreSQL’s wire protocol caps a bind at 65,535, so you’re technically under the ceiling, but planning time and statement-cache behaviour degrade a long way before that, and Hibernate’s query plan cache gets no reuse because the parameter count changes on every call. You can chunk it, and now you have a loop again, just with a better constant factor.

There’s also an indexing trap here that cost me more time than the fix did. If your join table is polymorphic (one table holding links for eight entity types, discriminated by a type column), the natural query is “give me every row where type = TAX_CODE”. If your only index starts with the foreign key column rather than the type column, that filter sequential-scans the whole table, and the query you wrote to avoid nineteen thousand fast queries turns into one slow one.

Rung four: don’t load the entities.

@Query("""
    select link.id.linkedEntityId as linkedEntityId,
           subsidiary.name as subsidiaryName
    from SubsidiaryLink link
    join TaxCode taxCode on taxCode.id = link.id.linkedEntityId
    join Subsidiary subsidiary on subsidiary.id = link.id.subsidiaryId
    where link.id.linkedEntityType = :linkedEntityType
      and taxCode.tenantId = :tenantId
    """)
List<LinkedSubsidiaryNameProjection> findTaxCodeSubsidiaryNames(
    UUID tenantId, LinkedEntityType linkedEntityType);

One query. Returns a flat projection, not entities, so nothing enters the persistence context and nothing can be lazily anything. Group it into a Map<UUID, Set<String>> in the service, hand it to the mapper, and change the factory signature so the fetch has to come from outside:

public static TaxCodeExportPayload fromTaxCode(
    TaxCode taxCode, Set<String> subsidiaryNames) {

That signature change is the actual fix, and it’s worth more than the query. It makes the data dependency explicit at the type level. Nobody can call this factory without having already decided where the subsidiary names come from, which means the invisible fetch from section 6 is now impossible to write by accident. The N+1 becomes a compile error.

Notice the join goes through TaxCode rather than filtering on the type column, which sidesteps the index problem from rung three, and it takes zero bind parameters instead of twenty thousand, which sidesteps the other one.

9. The part that isn’t about Hibernate

The rung-four version is two queries where the original was nineteen thousand, and I could tell you that’s a Hibernate lesson. It mostly isn’t.

An export endpoint wanted a flat list of names. It got there by loading twenty thousand fully managed, dirty-checked, snapshot-tracked entity instances and their nine hundred thousand association rows into a persistence context, so that it could read one string off each one and throw all of it away. Every single piece of that machinery is there to support writes. The export doesn’t write anything.

Lazy loading didn’t cause that. It just made it survivable for long enough that nobody questioned it, right up until a tenant got big enough to turn a design smell into a thirty-second timeout. The annotation defaults are worth knowing cold, and optional = false is worth typing, and default_batch_fetch_size is worth setting on day one of a project. But the question that would have prevented the whole afternoon is older and simpler than any of it: what do I actually need out of the database, and why am I asking for objects when I wanted strings?

If you want the write-side companion to this, the earlier piece on save, saveAndFlush and REQUIRES_NEW covers what the persistence context does when you are writing, and threads, @Async and @Transactional covers what holds a connection while all of this is happening. And for the other way a thirty-second timeout can ruin your day, there’s the empty array Jackson refused to parse.

Related Posts

save, saveAndFlush, and REQUIRES_NEW: The Hibernate Questions Hiding in One Spring Service Method

Last week I was reading a teammate’s PR and stopped on a method that made me realise something: I’d been using save, flush, commit, and REQUIRES_NEW correctly for years — and if a junior had asked me to explain precisely what each one does and when, my answer would have been thinner than my code. Using a tool and being able to teach it are different skills, and the gap only shows when someone asks.

Read more

Your Feature Flag Bill Is a Cache Key Problem

A client exceeded their feature flag vendor’s monthly request quota by 100%.

The two fixes everyone proposed were delete the dead flags and stop calling flags inside loops. Both are sensible. Both save exactly zero requests.

Read more

The Production Outage — Where the Fix Was Worse Than the Bug

One missing property line.

Two production outages.

The hot fix between them made the second one inevitable.

Read more