CodeGym /Courses /Spring Data JPA /Lazy loading and first access

Lazy loading and first access

Spring Data JPA
Level 21, Lesson 0
Available

1. The problem: “load everything at once”

If you are encountering ORM for the first time, it is very tempting to want it to behave like a kind waiter: “You ordered an order (order) — here are the items, products, categories, stock levels, and, just in case, our chef’s borscht recipe.” The problem is that the database does not know how curious you are today, and Hibernate is not a telepath (and, by the way, that is its best feature). In reality, “load everything at once” almost always means load too much, spend extra time, memory, network — and make reading expensive even where you only needed two columns.

Imagine that we have a piece of the graph that is typical for the project:

flowchart LR
  O[CustomerOrder] --> I[OrderItem]
  I --> P[Product]
  P --> C[Category]
  P --> S[StockItem]

Now let us define two reading scenarios:

The first is “show a list of recent orders: number, status, total.” This is a typical admin table or “order history.” Here we do not need items, we do not need product, we do not need category, and especially we do not need stockItem. We need literally three or four fields from the root.

The second is “show order details: items, products, prices.” Here we already need items, and most likely product. But even here you usually do not need the full product card and do not need all product associations.

If the ORM always loads everything, it will turn the first scenario, which is light, into the second one, which is expensive, even when you did not ask for that. And if you have a list of 50 orders, each with 10 items, then “everything at once” suddenly turns into a huge volume of data. And yes, “huge” happens sooner than you think: one bad screen in the admin panel is enough.

From this comes the basic idea: by default, read the root, and pull in the branches of the graph only when the code really needs them.

2. Lazy loading: what is deferred and what it promises

Lazy loading does not mean “there is no data,” and it does not mean “Hibernate decided to troll you.” It is an agreement: associated data is not loaded when the root entity is read, but later, at the first real access to the association. In other words, first you get an object that can give you the association, but does not have to have its data right now.

In JPA this looks as harmless as possible: you simply write fetch = FetchType.LAZY and that is it. But behind it lies an important distinction: “the entity is loaded” and “all entity associations are loaded” are two different facts.

Let us recall the example of OrderItem -> Product, which is very natural for a mini-shop:

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity // JPA entity: mapped to a table
public class OrderItem {

    @Id // Primary key of the order item row
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY) // LAZY: Product will be loaded on the first real access
    private Product product;
}

And using the CustomerOrder -> items example:

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@Entity // Order entity
public class CustomerOrder {

    @Id // Order identifier
    private Long id;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) // LAZY: do not load items when reading the order
    private List<OrderItem> items = new ArrayList<>(); // Important: this field may be a Hibernate wrapper
}

In both cases the ORM is basically telling you the following: “I can give you order.getItems() and item.getProduct(), but I never promised that this data is already in memory. If it is needed, I will go to the database and fetch it. But only if I have the right and the ability to do so.”

The key word is fetch. Lazy loading is not about “never load,” but about “do not load too early.”

3. The root and associations: two steps of reading

When you call findById or any other query, first an initial SELECT is executed, which picks the columns of the root entity. If the entity has @ManyToOne, the table row usually contains an FK (for example, product_id), and this allows the ORM to remember “where the association points” without loading the Product object itself.

The idea looks like this:

sequenceDiagram
    participant S as OrderService
    participant R as Repository
    participant DB as PostgreSQL

    S->>R: findById(orderId)
    R->>DB: "SELECT ... FROM customer_order WHERE id = ?"
    DB-->>R: "row with basic columns"
    R-->>S: "CustomerOrder (items = lazy wrapper)"
    S->>S: order.getItems().size()
    S->>DB: "SELECT ... FROM order_item WHERE order_id = ?"
    DB-->>S: rows for items

This is not a trick and not a workaround. It is a way to make reading manageable. First get what is definitely needed, the root, and only then, on demand, get what may be needed, the associations.

An important psychological point: calling a getter on an association does not always mean loading the data. Sometimes it is just “give you a wrapper object” for a collection or “give you a proxy reference” for a to-one association. And then the interesting part begins: which actions make the ORM say, “Fine, I will have to go to the database”?

4. First access to to-one associations

With to-one associations (for example, OrderItem -> Product, Product -> Category, Product -> StockItem) lazy loading is usually arranged like this: instead of the real object Hibernate can substitute a proxy object. It looks like Product, behaves like Product, but inside it keeps “for now” only the identifier and a mechanism that can load the remaining fields when needed.

Here is a small demonstration in the style of “let us see who you really are”:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderItemDebugService {

    private final OrderItemRepository orderItemRepository;

    public OrderItemDebugService(OrderItemRepository orderItemRepository) {
        this.orderItemRepository = orderItemRepository;
    }

    @Transactional(readOnly = true) // Important: the transaction keeps the Persistence Context, so proxies can be loaded later
    public void printProductClass(Long itemId) {
        // Load the order item (Product remains lazy)
        OrderItem item = orderItemRepository.findById(itemId).orElseThrow();

        // Look at the real field class: this is often HibernateProxy, not Product
        System.out.println(item.getProduct().getClass().getName());
        // for example: com.example...Product$HibernateProxy$...
    }
}

Notice that we called getProduct, but in this example we did not ask for either getName or getPrice. We simply looked at the class. Such “eye contact” usually does not require additional data.

And the “first real access” is when you do something that cannot be performed without the data of the associated entity. For example:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderItemDebugService {

    private final OrderItemRepository orderItemRepository;

    public OrderItemDebugService(OrderItemRepository orderItemRepository) {
        this.orderItemRepository = orderItemRepository;
    }

    @Transactional(readOnly = true) // Without a transaction (and an open context) it is easy to run into lazy problems here
    public String loadProductName(Long itemId) {
        // At this step Product may still be a proxy, without fields
        OrderItem item = orderItemRepository.findById(itemId).orElseThrow();

        // Here the ORM already needs Product data: a SQL query for loading will be executed
        return item.getProduct().getName();
    }
}

It is precisely the getName call (or any other “real” access to product fields) that most often becomes the very first access after which SQL goes to the database.

To avoid turning this into a set of “magic rules,” it is easier to think this way: first access is any action that really needs the object, not just a reference to it. The moment you ask for a field value, the ORM must fetch it.

Below is a table that helps prevent confusion (and arguments with colleagues at the level of “I think”):

Code Usually triggers SQL for lazy to-one? Why
item.getProduct() usually not you received a reference/proxy
item.getProduct().getId() usually not the id is usually known without loading the whole object
item.getProduct().getName() yes name is data from the product table
item.getProduct().toString() almost certainly yes toString usually reads fields, and fields need to come from the DB
log.info("{}", item.getProduct()) often yes logging calls toString or accesses fields

I deliberately use the words “usually,” “most often,” “often”: the exact moment may depend on implementation details and the provider, but SQL logs do not lie. If after a line of code you saw select ... from product where id=?, then that was your “first access” in practice.

5. First access to collections

With collections (CustomerOrder.items) the situation is even trickier for a beginner: you can receive the collection as an object, but it can still be empty “in its head” (not loaded) until you start asking it for elements.

That is, order.getItems() often returns not an ArrayList, but a special Hibernate wrapper collection that can go to the database and load elements when needed. It looks “like a list,” but behaves like “a list with database access.”

Let us show this without philosophy:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderDebugService {

    private final CustomerOrderRepository orderRepository;

    public OrderDebugService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true) // A transaction is needed so that the lazy collection can load on access
    public void printItemsClass(Long orderId) {
        // Load only the order itself (without items)
        CustomerOrder order = orderRepository.findById(orderId).orElseThrow();

        // Check the type of items: most often this is PersistentBag/Set etc.
        System.out.println(order.getItems().getClass().getName());
        // for example: org.hibernate.collection.spi.PersistentBag
    }
}

Now the most important thing: what counts as the “first access” to a collection? It is any action that needs elements of the collection, or at least their count.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderReadService {

    private final CustomerOrderRepository orderRepository;

    public OrderReadService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true) // A transaction is needed because size() may trigger a query
    public int countItems(Long orderId) {
        CustomerOrder order = orderRepository.findById(orderId).orElseThrow();

        // For a lazy collection, size() is often a real trip to the database
        return order.getItems().size();
    }
}

And here is an important discipline of thought: size() is not an “innocent collection operation” like in ordinary Java life. For a lazy collection, size() often means “please go to the database and figure out how many elements are there.”

A table that usually saves you from surprise:

Code Usually triggers SQL for lazy collection? Why
order.getItems() usually not you received a wrapper, not the elements
order.getItems().size() yes need size → need data
order.getItems().isEmpty() yes need to know whether it is empty → need data
for (var i : order.getItems()) yes iteration requires elements
order.getItems().stream() yes there is nothing to stream without elements
order.getItems().get(0) yes a specific element is needed

Once again: “first access” is not “when you got a reference to the collection,” but “when you did something that requires its contents.”

Example: order → items → product

When you learn lazy loading from abstract examples, the brain often says: “Okay, proxies, wrappers... where is my real pain?” Let us build a scenario that is as close as possible to real application code.

Imagine that we need to get a “short order description” for text in logs or for a response in some read-use-case service: the order number and the name of the first product. That is a chain of two lazy steps at once: first the items collection, then the to-one product association.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderSnippetService {

    private final CustomerOrderRepository orderRepository;

    public OrderSnippetService(CustomerOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional(readOnly = true) // Inside the transaction both lazy steps can be loaded later
    public String describeFirstItem(Long orderId) {
        // At this query we read only the root: CustomerOrder
        CustomerOrder order = orderRepository.findById(orderId).orElseThrow();

        // 1) First access to items: Hibernate loads the order items
        OrderItem first = order.getItems().get(0);

        // 2) First access to product: Hibernate loads the Product itself
        String productName = first.getProduct().getName();

        return order.getOrderNumber() + ": " + productName;
    }
}

If SQL logs are enabled, you will see a sequence of meanings roughly like this: first a SELECT for the order, then a SELECT for the order items, then a SELECT for the product.

And here is a very practical idea that will help you in the next lectures of the day: the cost of reading is determined by where the code actually “touches”. Not by the fact that “there is an association in the entity,” but by the fact that a particular use case walked through that association.

This is also an example that helps prevent confusion: “first access” is not an abstract category, it is the specific get(0) and getName line that changed the program’s behavior: “just an object in memory” turned into “an object that went to the database.”

6. Accidental first access: logging and toString()

The most unpleasant thing about lazy loading for a beginner is not that it exists, but that it sometimes turns on “in the wrong place.” You seem to write business code carefully, then you add a harmless log — and suddenly the application starts executing queries by itself. At that moment the myth “Hibernate makes unnecessary queries” is usually born. In practice, almost always the culprit is accidental first access.

A classic anti-example is a toString that digs into collections:

@Override
public String toString() {
    // Attention: items.size() may trigger loading of the lazy collection (and SQL) at an unexpected moment
    return "CustomerOrder{orderNumber='" + orderNumber
            + "', items=" + items.size() + "}";
}

Looks nice. In practice, items.size() can become the very first access to the lazy collection, and in the most unexpected places: in logging, in the IDE debugger, in exceptions, in tests. And if the entity already lives outside the context, such a call may end in failure. What matters here is the fact itself: technical code can also become the first access to a lazy association.

A safer style is to make toString only from simple root fields, without associations:

@Override
public String toString() {
    return "CustomerOrder{orderNumber='" + orderNumber
            + "', totalAmount=" + totalAmount + "}";
}

Yes, sometimes you want it to “look nice.” But if you have to choose between “nice” and “predictable,” in the data layer predictability almost always wins. Especially in a learning project, where we want to see the cause-and-effect chain “code → SQL,” not “code → surprise.”

7. Typical mistakes in lazy loading

It is very easy to step on rakes in this topic, because the Java code looks innocent, but the consequences show up only in SQL logs and response time. Below are the mistakes I see most often when students first start living with lazy loading in a real project, not in the tutorial world of “one entity — one table — one query.”

Mistake No. 1: treating LAZY as “there is no data”.
Lazy loading does not say “the association does not exist.” It says “the association data will be loaded later, if you really ask for it.” If you keep that in mind, half the panic of the form “my product is null” disappears, although in reality it is just a proxy there.

Mistake No. 2: thinking that the “first access” is only getXxx() on the association field.
For collections, the first access is almost never getItems(), but size(), isEmpty(), iteration, stream() or get(0). If you forget this, you will honestly wonder why “this short line” suddenly started going to the database.

Mistake No. 3: making toString() (or logging) traverse associations and collections.
It looks convenient, but it turns ordinary debugging into a scenario that changes application behavior. You wanted to “look at the object,” but accidentally asked the ORM to load half the catalog. That is usually where the legends about “Hibernate does everything by itself” begin to appear (and yes, by itself — but at your request, it was just hidden inside toString()).

Mistake No. 4: treating every unexpected load by switching associations to EAGER.
This is a “quick painkiller” that often makes things worse: reads become heavier, the entity graph grows, and the problem of controlling reads does not go away — it just gets masked. At the level of this lecture, it is enough to remember: EAGER is not a universal “make it good,” but just another compromise, sometimes a very expensive one.

Mistake No. 5: trying to understand lazy loading by intuition rather than by SQL.
Until you look at SQL logs, you will be arguing with reality at the level of “it seems to me it was loaded.” The simplest, most honest, and “non-mystical” method is to see after which line of code the SELECT went out. That is your first access in this scenario.

1
Task
Spring Data JPA, level 21, lesson 0
Locked
First access to a product via lazy many-to-one
First access to a product via lazy many-to-one
1
Task
Spring Data JPA, level 21, lesson 0
Locked
First access to a lazy collection through size()
First access to a lazy collection through size()
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION