Performance Tuning & the N+1 Problem

The N+1 problem shown concretely, and fixing it with JOIN FETCH, @EntityGraph, and batch fetching.

The N+1 problem, concretely

Consider loading a list of orders and, for each one, reading its customer's email — a completely ordinary thing to do:

Java
List<Order> orders = orderRepository.findAll(); // query #1

for (Order order : orders) {
    System.out.println(order.getCustomer().getEmail()); // lazy -- triggers its OWN query, per order
}

With customer mapped FetchType.LAZY (the recommended default), the generated SQL looks like this for, say, 50 orders:

SQL
-- Query 1: load the orders
SELECT * FROM orders;

-- Query 2 through 51: one SEPARATE query per order, to load its customer
SELECT * FROM customers WHERE id = ?;  -- for order #1's customer_id
SELECT * FROM customers WHERE id = ?;  -- for order #2's customer_id
-- ...48 more, one per order

One query became 51 — "N+1," where N is the number of parent rows. Each individual query is fast, but round-tripping to the database N separate times, instead of once, is often the single biggest, most easily overlooked source of slowness in a JPA/Hibernate application — and it's completely invisible from reading the Java code above, which looks entirely ordinary.

Fixing it with JOIN FETCH

A JPQL query can eagerly load an association as part of the same query, in one round trip, using JOIN FETCH instead of a plain JOIN:

Java
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o JOIN FETCH o.customer")
    List<Order> findAllWithCustomer();
}
SQL
SELECT o.*, c.*
FROM orders o
JOIN customers c ON o.customer_id = c.id;

One query, with the customer data already populated in memory — accessing order.getCustomer().getEmail() afterward triggers no additional query at all, because Hibernate already has the data from the join.

A plain JOIN (without FETCH) only affects the WHERE/filtering logic of the query — it does not populate the association, so order.getCustomer() would still lazily trigger a separate query afterward. JOIN FETCH is what actually tells Hibernate to load and attach the associated data.

Fixing it with @EntityGraph

@EntityGraph achieves the same result — declaring which associations to eagerly load for a specific query — without hand-writing JPQL:

Java
public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll();

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

This is often preferable to JOIN FETCH when the base query is otherwise just a plain derived method or the inherited findAll() — the @EntityGraph layers "and also fetch these associations" onto the query Spring Data would have generated anyway, without needing a full custom @Query.

Batch fetching: when you can't avoid some extra queries

Sometimes a collection genuinely can't be one big join (a very wide fan-out, or multiple different collections on the same entity, which Hibernate can't safely JOIN FETCH together at once). Batch fetching doesn't eliminate the extra queries, but groups them — instead of one query per parent, it's one query per batch of parents:

Java
@Entity
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @BatchSize(size = 20)
    @OneToMany(mappedBy = "customer")
    private List<Order> orders = new ArrayList<>();
}
SQL
-- Instead of one query per customer's orders, Hibernate batches lookups:
SELECT * FROM orders WHERE customer_id IN (?, ?, ?, ..., ?); -- up to 20 ids at once

The same effect can be set globally instead of per-entity:

Properties
spring.jpa.properties.hibernate.default_batch_fetch_size=20

Turning 50 individual customer-lookup queries into 3 batched queries (for a batch size of 20) is a smaller improvement than eliminating the extra round trips entirely with JOIN FETCH, but it's the right tool exactly when a genuine single join isn't practical.

Comparing the fixes

Approach Extra queries Best for
Do nothing (plain lazy loading) N (one per parent) Never, once N is more than a handful — this is the bug
JOIN FETCH 0 — one single query A specific query where you know up front you need the association
@EntityGraph 0 — one single query Same as above, layered onto a derived/inherited query method instead of custom JPQL
Batch fetching (@BatchSize) N / batch size Collections that can't cleanly be one join (multiple collections at once, very large fan-outs)

Finding N+1 in the first place

The problem is invisible in application code — it shows up only in the actual SQL Hibernate issues. Enabling SQL logging during development makes it visible immediately:

Properties
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.orm.jdbc.bind=trace

Seeing the same SELECT * FROM customers WHERE id = ? repeated dozens of times in the console, right after a single findAll(), is the unmistakable signature of N+1 in progress.

Common mistakes

  • Only noticing N+1 in production once a table has grown large enough for the extra round trips to actually show up as real latency — it's present (just harmless) from the very first line of code that introduces it, and worth checking for in code review, not just profiling later.
  • Defaulting associations to FetchType.EAGER to "fix" N+1 — this just moves the same over-fetching problem to every query that touches the entity, whether that query needed the association or not, instead of fetching it deliberately where it's actually needed.
  • Using JOIN FETCH with more than one collection association in the same query — Hibernate can't cartesian-join multiple collections safely in one query (it throws MultipleBagFetchException for Lists in this situation) — fetch one collection per query, or use Set instead of List, or batch-fetch the others instead.
  • Never turning on SQL logging during development — N+1 is easy to fix once you can see it and nearly impossible to notice by reading Java code alone.