How JPA lazy loading causes it, three real fixes with real tradeoffs, every JPA relationship annotation, cascade types, the first-level cache, LazyInitializationException, and a full Hibernate performance checklist.
Published September 23, 2026
The single most common JPA/Hibernate performance bug in production — and one nearly every team hits at least once, since it's invisible in development with small datasets and only surfaces as real latency at scale.
@Entity
class Order {
@Id Long id;
@ManyToOne(fetch = FetchType.LAZY)
Customer customer;
}
List<Order> orders = orderRepository.findAll(); // query 1: SELECT * FROM orders (N rows)
for (Order order : orders) {
System.out.println(order.getCustomer().getName()); // triggers query 2..N+1: one SELECT per order's customer
}
One query fetches the parent list (N orders). Then, because customer is lazily loaded, accessing order.getCustomer() for each order triggers a separate query to fetch that specific customer — N orders means 1 + N total queries instead of the 1 or 2 a well-written query could achieve.
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
Enabling Hibernate SQL logging surfaces the actual query count during development — but the more reliable detection method for catching regressions is a query-count assertion in integration tests (many teams use a Hibernate statistics interceptor or a library like db-util's QueryCountHolder to assert "this operation must execute at most 2 queries"), since N+1 bugs are easy to introduce accidentally and easy to miss in manual testing with a small dataset.
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
List<Order> findAllWithCustomer(); // ONE query, eagerly joins customer data in
Explicitly fetches the association in the same query via a SQL join — one round trip instead of N+1. The tradeoff: this is per-query, requiring a specifically-written JPQL query rather than the default findAll().
@EntityGraph(attributePaths = {"customer"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithCustomerGraph();
Declares a fetch plan per query method without changing the entity's own default fetch type globally — useful when some call sites need the association eagerly and others genuinely don't, avoiding a one-size-fits-all FetchType.EAGER on the entity itself (which would fetch the association on every load, even when unneeded, potentially trading an N+1 problem for an always-fetch-too-much problem).
hibernate:
default_batch_fetch_size: 25
Instead of one query per lazy association access, Hibernate batches them: turns N individual SELECT customer WHERE id = ? queries into ceil(N/25) queries of the shape SELECT customer WHERE id IN (?, ?, ..., 25 ids). This is a global, low-effort configuration change (no query rewriting needed) that meaningfully reduces query count without requiring JOIN FETCH/@EntityGraph at every call site — often the pragmatic first fix for an existing codebase with N+1 problems scattered across many queries.
@Entity
class Order {
@OneToMany Set<OrderItem> items;
@OneToMany Set<OrderNote> notes;
}
// JOIN FETCH on TWO collections in one query produces a CARTESIAN PRODUCT:
// N items * M notes rows returned, even though there are only N+M actual related rows
Joining two collection associations in a single query multiplies rather than adds rows — fetching both items and notes eagerly via a single query's joins produces a row for every (item, note) pair, not every item and every note separately, which can balloon the result set dramatically. This is exactly why eager-fetching is a tradeoff, not a strictly-better alternative to lazy loading — fixing N+1 by eagerly joining everything can introduce a different, sometimes worse performance problem.
@OneToOne — one entity instance associated with exactly one instance of another, commonly used to split a large entity (e.g. User and UserProfile) or to extend another entity's data without altering its table directly.@OneToMany / @ManyToOne — the most common relationship; the "many" side typically owns the foreign key column (a Order.customer_id column, not a collection column on Customer).@ManyToMany — requires a join table; in practice, often modeled as two @OneToMany relationships through an explicit join entity instead, giving control over additional data on the relationship itself (e.g. "when was this student enrolled in this course") that a bare @ManyToMany can't naturally hold.PERSIST, MERGE, REMOVE, REFRESH, ALL — control which operations on the parent entity automatically propagate to related child entities. Saving a parent with CascadeType.PERSIST automatically saves its new children too, without a separate explicit save call for each.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
List<OrderItem> items;
CascadeType.REMOVE only deletes a child when the parent itself is deleted. orphanRemoval = true additionally deletes a child when it's removed from the parent's collection (unlinked, e.g. order.getItems().remove(item)) even if the parent isn't deleted — without orphanRemoval, removing an item from the in-memory collection wouldn't delete its database row, just unlink it, leaving an orphaned row behind.
A session-scoped cache, always on by default, that automatically returns the same managed entity instance for repeated lookups of the same entity within one session/transaction — calling repository.findById(1L) twice in the same transaction returns the identical Java object reference the second time, without a second query. This is distinct from the second-level cache (a separate, opt-in, cross-session cache) and from external caching (Redis) — the first-level cache is automatic, scoped narrowly to one unit of work, and not something you configure or disable in normal use.
@Transactional
Order loadOrder(Long id) { return orderRepository.findById(id).get(); } // session open here
// Later, OUTSIDE the transaction:
order.getCustomer().getName(); // LazyInitializationException — session already closed
Thrown when accessing a lazy-loaded association after its owning Hibernate Session has closed — commonly the transaction boundary (@Transactional method) has already returned. This is genuinely common in production but often invisible in development, since dev environments sometimes have looser transaction/session boundaries (e.g. spring.jpa.open-in-view=true, which keeps the session open through view rendering, masking the problem until it's disabled or the architecture changes) that hide the bug until a specific request path triggers it.
A type-safe, programmatic alternative to writing JPQL strings for building dynamic queries at runtime (queries whose exact shape — which filters apply — depends on runtime conditions, like an optional search form with many optional fields). Trades JPQL's readability for compile-time type safety and much easier conditional query construction (if statements building up predicates, rather than string-concatenating JPQL fragments).
hibernate:
jdbc:
batch_size: 25
Groups multiple INSERT/UPDATE statements into fewer round trips to the database — distinct from default_batch_fetch_size (the read-side N+1 fix above); this setting is specifically for write throughput when persisting many entities in one transaction.
Hibernate automatically detects changes to managed entities at flush time by comparing current field values against a snapshot taken when the entity was loaded — issuing UPDATE statements automatically, without an explicit save() call, for any entity whose fields changed since loading. This is convenient but also a common source of surprise: modifying a managed entity's field inside a @Transactional method, with no explicit save, still persists the change at commit — worth knowing explicitly rather than discovering by accident.
@Query("SELECT o FROM Order o WHERE o.status = :status AND o.total > :minTotal")
List<Order> findByComplexCriteria(@Param("status") String status, @Param("minTotal") double minTotal);
Once query logic gets too complex for Spring Data's derived-query-method-name parsing (findByStatusAndTotalGreaterThan(...) becomes unwieldy past a certain complexity), @Query with explicit JPQL (or native SQL) takes over — the same escape hatch pattern as @Query on a Spring Data MongoDB repository.
Avoiding N+1 (via the three fixes above), right-sizing fetch strategies per actual access pattern (not defaulting everything to EAGER), batching writes, second-level cache for read-heavy reference data that rarely changes, and DTO projections (selecting only needed columns/fields directly into a DTO, skipping full entity hydration entirely) for read-heavy endpoints that don't need a fully managed entity.
Q: Why not just default every association to FetchType.EAGER and avoid N+1 entirely? A: Eager loading fetches the association on every single entity load, everywhere, whether that specific call site needs it or not — this trades N+1's per-access-site problem for a guaranteed-every-time overhead, and multiple eager collections risk the cartesian product blow-up shown above. Lazy-by-default with deliberate, per-query eager fetching (JOIN FETCH/@EntityGraph) where actually needed is the generally recommended approach.
Q: Does batch fetching eliminate N+1 entirely, or just reduce its severity? A: It reduces query count from N+1 to roughly N/batchSize + 1 — still more than the single query a JOIN FETCH achieves, but a large, configuration-only improvement over the unbatched case, which is exactly why it's a pragmatic retrofit for an existing codebase rather than a complete architectural fix.
Q: How does Hibernate's dirty checking interact with a DTO projection? A: It doesn't — a DTO projection isn't a managed entity, so Hibernate performs no dirty checking or automatic UPDATE tracking on it; DTOs are explicitly read-only projections, which is part of why they're cheaper (no snapshot comparison overhead) for read-heavy paths that never need to be saved back.
Q: Is spring.jpa.open-in-view a reasonable default to leave enabled? A: It's Spring Boot's default specifically because it prevents LazyInitializationException from surfacing during view rendering by keeping the session open longer — but it also masks N+1 problems and couples the persistence session's lifetime to the web layer's rendering, which many teams explicitly disable in production once they understand the tradeoff, preferring to fix fetch strategies deliberately instead of relying on an extended session to paper over them.