Skip to content
QSWEQB
mediumConceptScenarioMidSeniorStaff

This endpoint logs one query per row. Why are you seeing N+1 queries, and how do you fix them?

You get N+1 when one query loads N rows and something later dereferences a lazy association on each of them, firing a select apiece. Fix it per call site with join fetch, an entity graph, or batch fetching - not by changing mappings to EAGER.

5 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate name the exact statement that dereferences the proxy, rather than blaming lazy loading in the abstract
  • Whether EAGER is rejected for the right reason - that it is a global mapping decision and still issues per-row selects for query results
  • That they can separate join fetch, entity graphs, and batch fetching by what each one costs rather than treating them as synonyms
  • Whether pagination over a fetched collection is recognised as the case where join fetch is the wrong tool
  • Does the candidate propose a regression test that asserts on query count, not just on returned data

Answer

Where the extra queries come from

The "1" is the query you wrote. The "N" is one select per row, issued later, by code that does not look like it touches the database at all.

Take authorRepository.findAll() returning fifty authors, each with a @OneToMany collection of books. That mapping is lazy by default under JPA, so Hibernate does not populate the collection during findAll(). It puts a collection wrapper in the field instead - a PersistentBag or PersistentSet that knows its owner and its role but holds no rows. The field is never null, which is precisely why the bug is invisible on inspection. The moment anything iterates it, calls size(), or serialises it, that wrapper asks the session to initialise, and the session issues select ... from book where author_id = ?. Fifty authors, fifty selects.

To-one associations behave differently in two ways that matter. First, @ManyToOne and @OneToOne are EAGER by default under the JPA specification, so the N+1 there is often already present without anyone opting into laziness. Second, when you do mark them lazy and are not using bytecode enhancement, Hibernate hands you a proxy subclass of the entity. Reading the identifier getter is served from the proxy without SQL, because the proxy already holds the id. Every other method - a business getter, an overridden equals, toString, or Jackson reflecting over the object during serialisation - forces the select. This is why "we only log the ids" turns into an N+1 the day someone adds a field to the response DTO.

The reason this survives code review is that Spring Boot leaves spring.jpa.open-in-view enabled by default. The persistence context stays open through view rendering, so the lazy access succeeds instead of throwing LazyInitializationException. You do not get an error. You get a slow endpoint.

Why EAGER makes it worse, not better

The reflex fix is to change the mapping to FetchType.EAGER. It is wrong for three separate reasons, and an interviewer is listening for at least two of them.

It is not a fix at the level the problem exists. Fetch type on a mapping is a global statement about every query that ever loads that entity, but N+1 is a property of one call site. Making the association EAGER means the report that needs books also pays for it in the login path, the admin list, and the audit job.

It does not even remove the extra selects for query results. EntityManager.find() can resolve an EAGER association with a join, but a JPQL or Criteria query does not automatically join what your query did not ask for. Hibernate runs your select a from Author a, then issues a secondary select per row to satisfy the EAGER contract. You have kept the N+1 and lost the ability to opt out of it.

And once more than one EAGER collection is involved, the joins multiply rows against each other, so a parent with ten books and ten awards returns a hundred rows before Hibernate de-duplicates them in memory. Two EAGER List collections on the same entity fail outright with MultipleBagFetchException.

Join fetch, entity graphs, and batch fetching

These are three tools with different shapes, and treating them as interchangeable is the most common depth failure on this question.

join fetch puts the fetch plan in the query. It is explicit, it is one round trip, and it is per call site, which is exactly the granularity the problem has. In Hibernate 6 you no longer need distinct to collapse duplicated root entities - entity results are de-duplicated automatically, and the hibernate.query.passDistinctThrough hint that Hibernate 5 needed is gone. You still cannot join-fetch two bag collections in one query.

An entity graph is the same idea expressed declaratively, which lets you keep derived query methods. On a Spring Data repository you annotate the method and Hibernate applies a left join fetch for each listed path, so findAll() and a graph-annotated findAll() can coexist as separate methods over one mapping.

@BatchSize changes the shape of the problem rather than eliminating the second query. The collection still loads lazily, but Hibernate collects the uninitialised owners and loads them in in (?, ?, ...) batches, so fifty selects become five at a batch size of ten. Set it globally with hibernate.default_batch_fetch_size and it fixes N+1 across an entire codebase without touching a single query.

public interface AuthorRepository extends JpaRepository<Author, Long> {

    @Query("select a from Author a join fetch a.books where a.active = true")
    List<Author> findActiveWithBooks();          // one query, plan in the JPQL

    @EntityGraph(attributePaths = "books")
    List<Author> findByCountry(String country);  // one query, derived method kept
}

Pagination is where join fetch quietly breaks

Combine a collection join fetch with Pageable and the SQL limit becomes unusable, because one entity no longer corresponds to one row - limiting to twenty rows would truncate somebody's books. Hibernate does not fail. It fetches the entire result set and applies the offset and limit in memory, and it tells you so with WARN HHH000104: firstResult/maxResults specified with collection fetch; applying in memory.

That warning is the trap. It reads like a hint, the endpoint returns correct data, and it passes every test you have, so it stays in the log until the table grows and one request loads a million rows into heap. If you want it to be an error instead of a line nobody greps for, set hibernate.query.fail_on_pagination_over_collection_fetch to true and let the build tell you.

The fix is to keep the paged query row-per-entity. Either page the ids or the root entities first and issue a second query that join-fetches the collection for that id list, or leave the query alone and let @BatchSize bound the follow-up selects. Batch fetching is the low-friction answer here precisely because it never distorts the row count that pagination depends on.

Making the test fail

Assertions on returned data cannot see this bug, so assert on statement count. Enable spring.jpa.properties.hibernate.generate_statistics=true in the test profile and read getPrepareStatementCount() from the Hibernate Statistics around the call. Clear the statistics first, and flush and clear the EntityManager before the call under test, or entities already in the persistence context will satisfy the association without SQL and the test will pass for the wrong reason.

Statistics stats = entityManager.getEntityManagerFactory()
        .unwrap(SessionFactory.class).getStatistics();
stats.clear();
entityManager.clear();               // otherwise cached entities hide the N+1

List<Author> authors = repository.findActiveWithBooks();
authors.forEach(a -> a.getBooks().size());   // dereference, as the caller would

assertThat(stats.getPrepareStatementCount()).isEqualTo(1);

Pinning the exact number rather than an upper bound is deliberate: it turns any future mapping change that reintroduces a per-row select into a failing build. A datasource-proxy or p6spy interceptor gives you the same signal plus the SQL text, which is worth the extra dependency on a service where this has already bitten you once.

N+1 is a property of a call site, not of a mapping, which is why the fix belongs in the query or the fetch plan and never in the fetch type. The two answers that separate a strong candidate are that EAGER still issues per-row selects for query results, and that pagination over a collection fetch is silently done in memory.

Likely follow-ups

  • How would you page over authors and still load their books in a bounded number of queries?
  • What does spring.jpa.open-in-view do, and why does leaving it enabled hide this class of bug until production?
  • When can you touch a lazy @ManyToOne without hitting the database at all?
  • Why does join-fetching two List collections in one query fail, and what changes if one of them is a Set?

Related questions

Further reading

jpahibernaten-plus-onelazy-loadingentity-graph