In production systems handling millions of rows, a single N+1 query can bring throughput to its knees. The difference between lazy and eager fetching in JPA determines whether your application scales or crawls, and understanding when to use each is non-negotiable for anyone shipping real services. This guide walks through lazy vs eager fetching in JPA: best practices explained, backed by patterns we've refined across e-commerce platforms, trading systems, and high-traffic SaaS.
@ManyToOne is eager by default — change it
By default, JPA treats @ManyToOne relationships as eager. This means when you load an Order entity, Hibernate immediately fetches its related Customer. On the surface this sounds convenient — you get the full graph right away. In practice, it's a trap that bloats your queries and wastes memory on data you often don't need.
Consider an order listing page that shows order ID, date, and total. Do you really need to load 10,000 Customer records? No. But with eager loading, you get all of them.
@Entity
public class Order {
@Id
private Long id;
private LocalDateTime createdAt;
private BigDecimal total;
// Eager by default — this is the problem
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
}
The fix is immediate: declare @ManyToOne as lazy explicitly, and fetch only when needed.
@Entity
public class Order {
@Id
private Long id;
private LocalDateTime createdAt;
private BigDecimal total;
// Lazy by default — your new baseline
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
}
Once you make this change globally across your codebase, you've eliminated the silent performance leak. Your queries now fetch only the Order table unless you explicitly ask for the Customer. This one practice has cut database load by 30–50% in systems we've operated.
Eager loading cannot be undone per query
Eager loading is permanent at the entity level. If you define @ManyToOne(fetch = FetchType.EAGER), every query that touches that entity will load the relationship, whether you need it or not. There's no way to say "just this once, don't fetch the customer" in a later query.
This asymmetry is the core principle: lazy can be upgraded per query; eager cannot be downgraded. It's a one-way ratchet that commits you to always paying the cost.
The consequence becomes visible in a report query:
// Eager relationship — no way to avoid it
@Entity
public class Order {
@ManyToOne(fetch = FetchType.EAGER)
private Customer customer;
}
// Later, in a repository querying 100,000 orders
public List<Order> findAllOrders() {
return em.createQuery("SELECT o FROM Order o", Order.class)
.getResultList();
// Will load 100,000 orders AND 100,000 customers
// Cannot be undone even if you only need order IDs
}
If your entity definition locks in eager loading, and you later discover you need a lightweight query, you're forced to refactor the entire entity or work around it with projections. Starting with lazy is strategic — you retain flexibility for every future use case.
Fetch joins load data for a specific use case
Fetch joins are the tool for controlled eager loading. They let you specify exactly which relationships to load for a particular query, without changing the entity definition. This is the pattern you'll use 80% of the time when you need related data.
Suppose you're building an order detail page. Now you do need the Customer, Address, and Invoice data in a single response. Use a fetch join:
@Entity
public class Order {
@Id
private Long id;
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private Set<OrderItem> items;
}
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.customer c " +
"LEFT JOIN FETCH o.items i " +
"WHERE o.id = :id")
Optional<Order> findDetailById(@Param("id") Long id);
}
The DISTINCT keyword prevents cartesian products when you fetch multiple @OneToMany relationships. This query executes once, fetching the order, its customer, and all items in a single round trip. No N+1.
Fetch joins tell the database: "For this specific query, I need these relationships." Other queries against the same entity remain lazy, so you're not overfetching elsewhere.
DTO projections skip entities entirely
The most elegant solution for read-heavy operations is to skip entities altogether and project directly into DTOs. This is your best weapon against over-fetching.
When you're building an API response or populating a list, you often need only 3–5 fields from a complex entity graph. Constructing the full Order entity with all its relationships, then discarding most of it, wastes memory and CPU cycles.
DTO projections select exactly the fields you need and map them directly:
public record OrderSummaryDTO(
Long orderId,
LocalDateTime createdAt,
BigDecimal total,
String customerName,
String customerEmail
) {}
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query(
"SELECT new com.example.OrderSummaryDTO(" +
" o.id, o.createdAt, o.total, " +
" c.name, c.email " +
") FROM Order o " +
"JOIN o.customer c " +
"WHERE o.createdAt >= :since " +
"ORDER BY o.createdAt DESC"
)
Page<OrderSummaryDTO> findRecentOrders(
@Param("since") LocalDateTime since,
Pageable pageable
);
}
This query touches only the columns needed for the response. No entity instantiation, no lazy loading proxies, no wasted memory. The database returns structured data that maps directly to your DTO.
DTO projections are especially valuable for:
- List endpoints returning 100+ records
- Reports or dashboards aggregating data
- API responses where only specific fields are exposed
- Background jobs processing large datasets
Many teams reserve DTOs only for external APIs, but applying them to internal service-to-service calls and batch operations will dramatically reduce memory pressure and improve query performance.
Common mistakes
Mistake 1: Using @OneToMany(fetch = FetchType.EAGER)
Eager @OneToMany creates cartesian products. Fetching a Customer with 1,000 Orders using eager loading will hydrate 1,000 Customer objects, wasting memory and CPU. Always leave @OneToMany as lazy and use fetch joins when you need the data.
Mistake 2: Fetch joining multiple @OneToMany relationships without DISTINCT
A query joining Order → Items and Order → Shipments will multiply the result set. Use DISTINCT to deduplicate, and be aware that pagination with multiple collection joins is complex (many frameworks handle it by fetching in memory).
Mistake 3: Changing entity fetch strategies reactively
When a query times out, the instinct is to add eager loading. Resist it. Instead, audit that specific query: can you add a fetch join? Can you use a DTO projection? Can you add an index? Changing the entity definition is the last resort.
Mistake 4: Forgetting that lazy loading requires an open session
If you return an entity from a service layer and try to access a lazy relationship in the controller or view, you'll hit a LazyInitializationException. Either fetch the relationship before returning, use an open-session-in-view pattern (not recommended at scale), or use DTOs that contain pre-fetched data.
Key takeaways
- Make @ManyToOne lazy explicitly in every entity definition. Eager is a footgun.
- Lazy loading can be upgraded per query with fetch joins; eager loading cannot be downgraded. Start lazy.
- Use fetch joins in JPQL or Criteria queries when a specific use case needs related entities in a single round trip.
- Leverage DTO projections for read-heavy operations to select only the fields you need and avoid entity instantiation overhead.
- Profile your queries. N+1 problems surface under load, not in development. Use tools like spring-boot-starter-data-jpa query logging or p6spy to detect them early.
- The best fetching strategy depends on the use case. List endpoints often benefit from DTOs; detail pages often benefit from fetch joins.






Comments
Loading comments…