Lazy vs Eager Loading in JPA — What Gets Loaded and When

by Eric Hanson, Backend Developer at Clean Systems Consulting

The two fetch strategies

Eager loading retrieves the associated data in the same database operation as the parent entity. When you load an Order, eager associations are loaded immediately — whether you access them or not.

Lazy loading defers retrieval until the association is first accessed. When you load an Order, lazy associations are proxies. The database query fires when your code calls order.getLineItems() or order.getUser().

The default fetch types by association:

AssociationDefault
@ManyToOneEAGER
@OneToOneEAGER
@OneToManyLAZY
@ManyToManyLAZY

The EAGER default for @ManyToOne and @OneToOne is a legacy decision from JPA 1.0 that predates widespread understanding of its performance consequences. In most production codebases, it's wrong.

What eager loading actually does

An entity with eager associations generates a JOIN query:

@Entity
public class Order {
    @Id private Long id;

    @ManyToOne(fetch = FetchType.EAGER)  // the default
    private User user;

    @OneToOne(fetch = FetchType.EAGER)   // the default
    private ShippingAddress shippingAddress;
}

Loading this entity:

Order order = orderRepository.findById(1L).orElseThrow();

Generates:

SELECT o.*, u.*, sa.*
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN shipping_addresses sa ON sa.order_id = o.id
WHERE o.id = 1

Three tables in one query. Every load of Order includes user and shipping address data, regardless of whether either is ever accessed in the current request. This is the core problem: eager loading is unconditional.

For a query that returns 1,000 orders:

List<Order> orders = orderRepository.findAll();

With eager @ManyToOne to User, Hibernate generates a JOIN that returns 1,000 rows, each carrying the full user data — even for a method that only displays order IDs and statuses.

What lazy loading actually does

With lazy associations, Hibernate creates a proxy object that intercepts property access:

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
}
Order order = orderRepository.findById(1L).orElseThrow();
// SQL: SELECT * FROM orders WHERE id = 1
// user field contains a proxy — no SQL yet

String email = order.getUser().getEmail();
// SQL: SELECT * FROM users WHERE id = ?
// Triggered by .getEmail() which forces proxy initialization

The proxy intercepts the first method call on order.getUser(), fires the database query, populates the proxy with the actual data, and then delegates the method call to the loaded entity. Subsequent calls to order.getUser() return the loaded entity without additional queries.

The LazyInitializationException

Lazy loading requires an active Hibernate session. When you access a lazy association outside a transaction — after the session is closed — Hibernate cannot load the data:

@Service
public class OrderService {

    @Transactional
    public Order findOrder(Long id) {
        return orderRepository.findById(id).orElseThrow();
        // transaction ends when method returns — session closes
    }
}

// Controller
Order order = orderService.findOrder(1L);
order.getUser().getEmail(); // LazyInitializationException — session is closed

LazyInitializationException is the most common JPA error in Spring Boot applications. Three responses:

Fix 1: Load within the transaction. Use JOIN FETCH or @EntityGraph to load the association before the session closes:

@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :id")
Optional<Order> findByIdWithUser(@Param("id") Long id);

Fix 2: Keep the session open (anti-pattern). spring.jpa.open-in-view=true (the Spring Boot default!) extends the Hibernate session through the view rendering phase, allowing lazy loading in templates and serializers. This works but:

  • Holds a database connection through the entire request, including view rendering time
  • Masks the actual data loading that's happening — lazy loads in templates are invisible
  • Hides N+1 problems until load testing

Set spring.jpa.open-in-view=false and handle lazy loading explicitly. The warning Spring Boot logs at startup when OIDP is detected is telling you something real.

Fix 3: DTO projection. Load only what's needed at query time, returning a DTO rather than an entity with lazy associations:

public record OrderSummary(Long id, OrderStatus status, String userEmail) {}

@Query("SELECT new com.example.OrderSummary(o.id, o.status, u.email) " +
       "FROM Order o JOIN o.user u WHERE o.id = :id")
Optional<OrderSummary> findSummaryById(@Param("id") Long id);

No entity, no proxy, no lazy loading, no LazyInitializationException possible.

The correct default: lazy everything, explicit when needed

The production rule: all associations LAZY, eager loading only when explicitly requested per query.

@Entity
public class Order {
    @Id private Long id;

    @ManyToOne(fetch = FetchType.LAZY)    // override the default
    private User user;

    @OneToOne(fetch = FetchType.LAZY)     // override the default
    private ShippingAddress shippingAddress;

    @OneToMany(mappedBy = "order")        // already LAZY
    private List<LineItem> lineItems;
}

Now every query for Order is just:

SELECT * FROM orders WHERE ...

No joins unless explicitly requested. Queries that need the user add JOIN FETCH:

@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.status = :status")
List<Order> findByStatusWithUser(@Param("status") OrderStatus status);

Queries that don't need the user don't pay for it.

How Hibernate implements lazy loading — the proxy

For @ManyToOne and @OneToOne, Hibernate creates a CGLIB proxy — a subclass of User that intercepts all method calls. The proxy contains only the entity's ID. On first method call, it fires the query and delegates:

User user = order.getUser(); // returns a proxy
user instanceof User;        // true — proxy is a User subclass
user.getId();                // may return without a query (ID is stored in the proxy)
user.getEmail();             // fires SELECT * FROM users WHERE id = ?

user.getId() sometimes doesn't trigger a query — if Hibernate knows the ID (it does, from the foreign key column), it can answer the ID question from the proxy without loading the entity. This is the "proxy optimization" — accessing only the ID of an eager or lazy @ManyToOne doesn't always require loading the associated entity.

For @OneToMany and @ManyToMany, Hibernate uses a PersistentBag or PersistentList wrapper that loads the collection on first access.

Type-checking lazy proxies

A common bug with lazy proxies: instanceof and casting with lazy associations:

Entity entity = entityRepository.findById(id).orElseThrow();
AbstractEntity base = entity.getRelated(); // lazy proxy

if (base instanceof ConcreteSubclass) {  // false — proxy is not ConcreteSubclass
    ConcreteSubclass concrete = (ConcreteSubclass) base; // ClassCastException
}

The proxy is a subclass of the declared type (AbstractEntity), not of the actual runtime type (ConcreteSubclass). Hibernate provides Hibernate.unproxy() to get the actual entity:

AbstractEntity unproxied = Hibernate.unproxy(base, AbstractEntity.class);
if (unproxied instanceof ConcreteSubclass concrete) {
    // works correctly
}

Or force initialization before type checking:

Hibernate.initialize(base); // forces proxy initialization
if (base instanceof ConcreteSubclass) { // now works

Collection loading behavior

@OneToMany with lazy loading defers collection load until the collection is first accessed. But the behavior of the loaded collection is a source of bugs:

// Both of these trigger the collection load
int size = order.getLineItems().size();
boolean contains = order.getLineItems().contains(item);

// This triggers the load then executes a new query — does not use the loaded collection
long count = lineItemRepository.countByOrderId(order.getId());

// This ALSO triggers a new query, bypassing the loaded collection
List<LineItem> filtered = lineItemRepository.findByOrderIdAndStatus(
    order.getId(), LineItemStatus.BACKORDERED);

Once a lazy collection is initialized, Hibernate holds it in the persistence context. Methods that call back to the repository against the same data bypass the persistence context and issue new queries. For consistent behavior, use the initialized collection for in-memory operations and the repository for new queries — don't mix them.

The EAGER trap in inheritance hierarchies

With inheritance mapped as SINGLE_TABLE or JOINED, eager associations on superclass entities apply to all subclass queries. An eager @ManyToOne on BaseEntity means every query for ConcreteEntityA, ConcreteEntityB, and ConcreteEntityC eagerly loads that association:

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public abstract class BaseEntity {
    @ManyToOne(fetch = FetchType.EAGER)  // affects ALL subclass queries
    private Organization organization;
}

In inheritance hierarchies especially, lazy defaults and per-query eager loading prevent unintended joins across the entire hierarchy.

The diagnostic: check what's actually executing

Enable SQL logging in development and exercise the code paths that matter. The query log shows whether associations are loading when you didn't ask for them (eager loading you don't need) or generating N queries (lazy loading without batch fetching):

logging:
  level:
    org.hibernate.SQL: DEBUG

A correctly configured entity model shows:

  • One query per data load path, no automatic JOINs you didn't request
  • No repeated queries for the same association across a collection
  • JOIN FETCH or batch fetch queries where associations are actually needed

The goal is queries that match intent: load what you need, when you need it, at the level of granularity the use case requires.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Blocks, Procs, and Lambdas — A Practical Guide Without the Confusion

Ruby gives you three ways to package callable code, and most developers cargo-cult the choice. Here's a precise breakdown of the differences that actually affect behavior in production code.

Read more

Consistent Error Handling Across Your API Is Not a Nice to Have

Inconsistent error shapes across endpoints force developers to write defensive code for every route they touch. Consistency is not polish — it is correctness.

Read more

Vancouver Has World-Class Backend Engineers — Big Tech Hired Them at Rates Startups Cannot Match

Vancouver's engineering talent is genuinely exceptional. The companies that recognized this first built compensation structures around retaining it.

Read more

Melbourne Is Not Sydney — And Its Backend Hiring Challenges Are Entirely Its Own

Melbourne has a strong tech community and a distinct startup culture. Its backend hiring market has its own specific friction that founders often don't see coming.

Read more