The problem in one scenario

An endpoint returns the 50 most recent orders together with the name of each customer. The code looks innocent: load the orders with a repository method, map each one to a DTO, and call order.getCustomer().getName() inside the mapping. In a local database with five rows everything feels fast. Then the table grows and the endpoint slows down, although the Java code did not change.

What happened is called the N+1 query problem. Hibernate ran one query to load the orders (the "1") and then one additional query per order to load its lazy customer association (the "N"). Fifty orders mean fifty-one round trips to the database. Each round trip has fixed cost: network latency, statement parsing, a connection held from the pool. The cost grows linearly with the page size, and it is invisible in the code because the extra queries are triggered by a plain getter call.

Detecting it

Do not guess; look at the SQL. In development, enable statement logging:

spring:  jpa:    properties:      hibernate:        generate_statistics: truelogging:  level:    org.hibernate.SQL: debug

With this on, a request that triggers N+1 prints the same select ... from customer where id=? statement again and again. generate_statistics additionally reports the number of statements per session. A more durable approach is to assert the number of statements in an integration test, using a datasource proxy library or Hibernate's Statistics object, so that a regression fails the build instead of waiting for a slow-endpoint alert.

The default fetch type matters. @ManyToOne and @OneToOne are eager by default in the Jakarta Persistence specification, @OneToMany and @ManyToMany are lazy. Eager does not mean "joined": when you load entities through a JPQL query, Hibernate still issues follow-up selects for eager associations. The safest default is to declare associations fetch = FetchType.LAZY and decide per use case what to load.

Fix 1: fetch join

When a use case needs the association, say so in the query:

@Query(""    select o from Order o    join fetch o.customer    where o.status = :status    "")List<Order> findWithCustomer(@Param("status") OrderStatus status);

Hibernate now produces a single select with a join, and the customer is already initialized. A fetch join is explicit and easy to read, but it is tied to that one query method.

Fix 2: @EntityGraph

If you prefer derived query methods, an entity graph adds the fetch plan without writing JPQL:

@EntityGraph(attributePaths = {"customer"})List<Order> findByStatus(OrderStatus status);

The effect is similar to a fetch join, and it composes with Spring Data's method naming and Specification support. Keep graphs small; a graph that pulls five associations is a sign that the use case needs a dedicated read model.

Fix 3: batch fetching

Sometimes a join is not what you want, for example when you load a collection and the join would multiply rows. Hibernate can load lazy associations in batches instead of one by one:

spring:  jpa:    properties:      hibernate:        default_batch_fetch_size: 50

Now, when the first lazy customer is touched, Hibernate loads the customers for up to 50 pending proxies using an in (...) clause. Fifty-one queries become two. This is a good global safety net: it does not change the queries you wrote, it only softens the damage when someone forgets a fetch plan. It is not a replacement for deliberate fetching, because you still pay for extra round trips.

Fix 4: DTO projections

Often the endpoint does not need entities at all. If the response only needs the order id, total and customer name, ask for exactly that:

public record OrderRow(Long id, BigDecimal total, String customerName) {}@Query(""    select new com.example.OrderRow(o.id, o.total, c.name)    from Order o join o.customer c    where o.status = :status    "")List<OrderRow> findRows(@Param("status") OrderStatus status);

Projections read fewer columns, skip the persistence context and dirty checking, and cannot trigger lazy loading by accident. For read-heavy endpoints they are usually the best option. Entities remain the right tool when you intend to modify and save them.

Pagination with fetch joins

Fetch-joining a collection (join fetch o.items) together with Pageable is a classic trap. The join multiplies rows, so the database cannot apply limit to orders; Hibernate logs a warning about applying pagination in memory and loads the whole result set before cutting the page. Safer options:

  1. Page over the root entity ids first, then load the collections for those ids with a second query (where o.id in :ids).
  2. Fetch-join only to-one associations in the paged query, and use default_batch_fetch_size for the collections.
  3. Use a DTO projection and aggregate in the application or with a separate query.

Joining two collections in one query is another trap: it produces a Cartesian product and, with List types, can throw MultipleBagFetchException. Load one collection per query.

Common mistakes

  • Making every association EAGER to "fix" lazy loading exceptions. This moves the problem into every query.
  • Touching a lazy association in a mapper after the transaction ended and then enabling open-session-in-view to hide the error. Decide the fetch plan inside the service method.
  • Adding distinct and joins everywhere without looking at the generated SQL.
  • Testing with a tiny dataset where N is 2 and nothing looks wrong.

Trade-offs

A fetch join gives one query but a wider result set with repeated parent columns. Batch fetching keeps queries small but adds a few round trips. Projections give the best control but mean another class to maintain. There is no universal answer: pick per endpoint and verify with the SQL log. Also remember that fewer queries is not always faster; an unindexed join can be slower than several indexed lookups, which is why index design belongs in the same conversation.

A practical decision rule

When writing a new endpoint, ask yourself three questions. Does the response need the whole entity, or only a few fields? If only a few fields are needed, choose a projection and you are done. If the entity will be modified, use a fetch join or an entity graph for to-one associations. If collections are needed and the result is paginated, paginate the ids first and then load the collections with a separate query. The answers to these three questions almost always determine the right fetch plan.

Ask the same questions in review. If you see a getX().getY() chain in a mapper, check whether that association is loaded by the query. Opening the SQL log every time is tiring, so for critical endpoints it pays off faster to write a test that checks the statement count: the test is written once and then guards silently on every build.

Checklist

  • Associations are declared LAZY unless there is a strong reason.
  • Every endpoint has a deliberate fetch plan: fetch join, entity graph or projection.
  • default_batch_fetch_size is set as a safety net.
  • No collection fetch join is combined with Pageable.
  • A test asserts the number of SQL statements for the critical endpoints.

Where to go next

Lazy loading only works inside an open transaction, so read Spring transaction pitfalls. To make the statement-count assertion part of your suite, see testing strategy for backends. To check how comfortable you are with persistence topics, try the skills assessment.