1. Introduction
Imagine that you open a typical backoffice screen: an order list, a product card, a quick customer view. A beginner developer often has the noble desire to “let’s load everything at once so we definitely won’t need to later.” In the ORM world, this desire very quickly turns into the habit of dragging half the database along, even when you only need a single field.
Hibernate, in turn, tries to be practical. It does not want every one of your find() calls to turn into a huge combine harvester of JOINs and a gigantic object graph. So it has a strategy: “if a relationship is marked LAZY, I can give you a reference first, and fetch the data later if you actually go after it.” That is lazy loading in plain language.
It is important not to confuse things here. Lazy loading is not “Hibernate will never load the associated entity.” Lazy loading is about the deferred moment of loading. Hibernate is basically saying: “Right now you asked about the order. Here is the order. I will bring the customer only if you ask for their email, name, status... or anything else besides the id.”
We have already seen that a managed object lives together with the persistence context, while a detached one does not. Now that knowledge becomes very practical: a lazy reference can continue reading data only while that context is alive. This is exactly what later leads to late SQL on an ordinary getter and to LazyInitializationException when the context is already gone.
2. To-one relationships in Commerce Persistence Lab
To keep lazy loading from staying abstract, we will stick to concrete relationships from our training project Commerce Persistence Lab. It has two very characteristic to-one scenarios: an order knows its customer, and a product knows its details. For the basic mechanism, we will rely primarily on PurchaseOrder.customer: with ManyToOne, lazy behavior is usually clearer and more stable. Product.details is useful as a secondary case, but with @OneToOne(fetch = FetchType.LAZY) the behavior depends more strongly on the specific mapping and provider support, so we will not make it the main reference point. Both cases are real-world ones: in an order you often need customerId, but not always the entire customer card; in a product you often need name and price, but the details (description, specifications) are only needed on the detail screen.
Let us start with the most typical variant — ManyToOne: order → customer. At the entity level it looks like this:
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
@Entity
public class PurchaseOrder {
@Id
private Long id;
// LAZY: Hibernate can return a proxy instead of a real Customer
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
}
The key line here is fetch = FetchType.LAZY. It means that Hibernate has the right not to load Customer together with PurchaseOrder.
Let us also look at OneToOne — product → product details. This is a useful secondary scenario: in our project, ProductDetails cleanly separates the “product card” from the “product list,” where details are not always needed.
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
@Entity
public class Product {
@Id
private Long id;
// LAZY: details may be loaded only when the data is actually accessed
@OneToOne(fetch = FetchType.LAZY)
private ProductDetails details;
}
Here it is important to understand the general principle: a to-one relationship is always an object field, not a collection. In Java it looks harmless: private Customer customer; — just a reference, so what? But this is exactly where Hibernate gets the ability to place a special replacement object instead of the “real customer.”
3. FetchType.LAZY: the field is not null, but the data is not there yet
When a beginner developer first encounters FetchType.LAZY, they often expect something like “the field will be null until we load it.” But Hibernate is not a manual lazy-initialization library. It will not make you write if(customer == null) loadCustomer(). Instead, it tries to preserve the convenience of the object world for you: the field is not null, but it may be a not fully loaded object.
Let us look at the basic scenario:
import jakarta.persistence.EntityManager;
// Load the order: Customer may not be read from the database fully with LAZY
PurchaseOrder order = entityManager.find(PurchaseOrder.class, 10L);
// Get the relationship reference (often this is a proxy, not a "real" Customer)
Customer customer = order.getCustomer();
System.out.println(order.getId()); // 10 (this field was definitely read)
At this point many people subconsciously draw the conclusion: “If customer is not null, then the customer has already been read from the database.” And that is where the main trap begins. order.getCustomer() may return not the real Customer, but a proxy object that does not yet contain the email, name, and everything else. So in your hand you do not have a “customer,” but a “customer card” that has the id written on it and the ORM dispatcher’s phone number: “if you need details, call me and I will arrange a database read.”
Why is this convenient for Hibernate (and for us too)? Because when loading an order from the database, most of the time it is enough to read only the orders table. For example, to show orderNumber, status, createdAt. And the customer should be loaded only if the business code really goes after customer data.
There is one more subtle point related to the first-level cache. If Customer has already been loaded into the current persistence context (for example, you previously called find(Customer.class, 5L)), then accessing order.getCustomer() may be instantly “resolved” to the already existing managed object and not issue additional SQL. In other words, lazy does not mean “there will definitely be a second query”; lazy means “Hibernate has the freedom to decide: load now or later.”
4. Proxy object: the “stand-in” that plays Customer
To understand where SQL will happen, it helps to know what proxy is at the level of “what object is in my variable.” A proxy in Hibernate is a replacement object that looks like an entity, but internally contains minimal information and can “load itself” from the database on the first serious access. If we use an analogy, it is a stunt double: someone is already standing on stage, but the real lines begin only when a close-up is needed.
In Java this is usually implemented like this: Hibernate creates a subclass of your entity (or an object compatible with it) that intercepts method calls. So you may see something surprising:
Customer customer = order.getCustomer(); // in practice this is often a proxy
// Proxy is a "Customer" from the point of view of instanceof
System.out.println(customer instanceof Customer); // true
// But the real class will be generated by Hibernate (subclass/proxy wrapper)
System.out.println(customer.getClass().getName()); // ...Customer$HibernateProxy$...
In comments, I would expect roughly the following output:
System.out.println(customer instanceof Customer); // true
System.out.println(customer.getClass().getName()); // com.example.commerce.customer.entity.Customer$HibernateProxy$...
So instanceof Customer is true, because the proxy behaves like Customer. But getClass() shows not your “plain” Customer, but a “special version” from Hibernate.
Why does this matter? Because proxy is the main way Hibernate implements the idea “give the reference now, load later.” A proxy usually already knows the identifier (the same customer_id from the purchase_order table), and the proxy knows which Session/EntityManager it is attached to so it can go to the database later.
As long as you look at the proxy as a “normal customer,” you will keep wondering: “why does this method suddenly make SQL?” Once you start thinking “I may have a proxy in my hand,” everything becomes calmer and more predictable. This is not chaos. It is just deferred work.
5. Proxy initialization: the moment of real SQL
The main engineering point in to-one lazy loading is the moment of initialization of the proxy. Initialization is when Hibernate understands: “okay, the developer is really going after the data, time to issue SELECT.” From the outside it almost always looks the same: you call some getter on the object, and suddenly a query to the related table appears in the SQL log.
Classic example with an order and a customer:
import jakarta.persistence.EntityManager;
PurchaseOrder order = entityManager.find(PurchaseOrder.class, 10L);
// There is usually no SELECT for customer yet: this is a reference/proxy
Customer customer = order.getCustomer();
System.out.println(customer.getId()); // 5 (often without SELECT: id is already known from FK)
System.out.println(customer.getEmail()); // a SELECT may happen here: real fields are needed
Pay attention to getId() and getEmail(). Why does getId() often work without SQL? Because the id is already known: it was read together with the order as the foreign key value customer_id. Hibernate can return it from the proxy without loading the customer row completely.
But getEmail() is already customer business data. If it has not been loaded yet, Hibernate has to issue a query.
If you enabled SQL trace (the sql-trace profile from our lab), the picture usually looks like this:
-- 1) loaded the order (read customer_id as the foreign key)
select po.id, po.customer_id
from purchase_order po
where po.id = 10;
-- 2) went after the customer's email — Hibernate initializes the proxy and loads the data
select c.id, c.email, c.first_name, c.last_name
from customer c
where c.id = 5;
To make “initialization” completely obvious, you can use a small Hibernate helper, Hibernate.isInitialized(...). It is not an everyday tool, but it is great for learning: you can literally see whether the object is initialized or not yet.
import org.hibernate.Hibernate;
Customer customer = order.getCustomer(); // often a proxy
System.out.println(Hibernate.isInitialized(customer)); // false: data has not been loaded yet
customer.getEmail(); // triggers SQL (if the data is not yet in the persistence context)
System.out.println(Hibernate.isInitialized(customer)); // true: after initialization, fields are available without extra SELECT
A typical output will look like this:
System.out.println(Hibernate.isInitialized(customer)); // false
customer.getEmail(); // (SQL went to the database)
System.out.println(Hibernate.isInitialized(customer)); // true
Another important point: if the customer is already loaded into the persistence context, proxy “initialization” may happen without SQL. Hibernate will say: “aha, Customer#5 is already a managed object here, take it,” and the query will not be needed. This is a direct continuation of the identity map model.
6. Practice: reading, writing, model
find() and getReference() as a “manual” version
This pattern is already familiar from order.getCustomer(): first a reference, then on the first real access to the data, SQL may appear. find() and getReference() show the same idea directly: find() reads the entity immediately, while getReference() first gives a reference and postpones reading the data.
Customer eagerLike = entityManager.find(Customer.class, 5L); // SELECT immediately
Customer lazyLike = entityManager.getReference(Customer.class, 5L); // reference without immediate SELECT
System.out.println(eagerLike.getEmail()); // data is already in memory
System.out.println(lazyLike.getId()); // id is usually known without SELECT
System.out.println(lazyLike.getEmail()); // a SELECT may be needed here
Therefore, order.getCustomer() feels closer to getReference() than to find(): first you have a reference to the associated customer, and the reading of business fields may happen later. For write scenarios this is especially useful, because sometimes we need exactly the foreign key, not a full read of the customer row.
How to set a relationship without unnecessary reading
To-one lazy is not only a reading story. It is also about writing: Hibernate can work with foreign keys in a way that does not load the associated entity if you only need to point to it. This is especially useful in write scenarios when you already have a customerId, and you create an order without touching all customer fields.
Imagine a typical service scenario: “create a new order for customer id=5.” If you write the code naively, you might first load the customer and then assign it to the order. But sometimes that is unnecessary: if the business logic does not need to check the customer status and does not need the customer’s fields, you can simply point to it.
Here is the minimal variant:
import jakarta.persistence.EntityManager;
Customer customerRef = entityManager.getReference(Customer.class, 5L);
PurchaseOrder order = new PurchaseOrder();
order.setCustomer(customerRef);
entityManager.persist(order);
Note: here we did not call find(Customer.class, 5L). We did not read the customer’s email, name, or status. We simply told Hibernate: “here is a reference to customer #5, put it into the order as the FK.” And Hibernate can quite happily issue an INSERT into purchase_order with the customer_id = 5 column, without running a SELECT against customer.
In the SQL log this often looks roughly like this:
insert into purchase_order (customer_id, id) values (5, 123);
And that is it. No “let’s read the customer first.” This is not “optimization for the sake of optimization,” it is simply correct use of the ORM model: the relationship is stored in the database as a foreign key, and to write a foreign key you do not have to drag in the entire related table row.
Of course, if you need to check that the customer is active, or if you need to use their email for business logic, then reading is unavoidable and even useful. But the important thing is freedom: Hibernate gives you the ability to separate “point to” and “read data.”
The mental model “reference != data”
If you are internalizing lazy loading, the main thing is to stop reading code like plain Java. In plain Java order.getCustomer() really means “I have a Customer object.” In Hibernate it means “I have an object that may be a proxy and may lead to SQL when I go after the data.”
Here is a neat diagram of what happens in to-one lazy:
flowchart TD
A["SELECT purchase_order (order loaded)"] --> B["order.customer = proxy (customer_id known)"]
B --> C{"Need customer data?"}
C -->|"no"| D["Continue working without SQL to customer"]
C -->|"yes: getEmail()/getStatus()..."| E["proxy initialized through Session"]
E --> F["SELECT customer WHERE id = ?"]
F --> G["proxy is now initialized, data is in memory"]
And one more small table that helps you catch the moment of SQL with your eyes:
| In code | What it means | What may happen |
|---|---|---|
| order.getCustomer() | get a relationship reference | often without SQL |
| order.getCustomer().getId() | get the identifier | often without SQL (id is already known) |
| order.getCustomer().getEmail() | get business data | may trigger SELECT |
| repeat getEmail() | data is already loaded | usually without SQL |
If you keep this model in mind, it becomes easier to read any service code: you can literally see the places where an “innocent” getter may be a SQL point. And you stop being surprised by “why did the query happen outside the repository?”
7. Typical mistakes when working with lazy proxies in to-one relationships
Proxy and lazy loading are the places where many people “lose trust” in ORM, because the behavior seems unpredictable. In practice it is predictable, just unfamiliar: SQL is tied not to the line of code “in the repository,” but to the moment when you request the data. Below are the most common mistakes I see among beginners (and sometimes in my own old projects, where I was also a beginner once).
Mistake #1: assuming that order.getCustomer() means “the customer is already loaded.”
It is very tempting to think that a getter always returns a “full object.” But in the lazy model, a getter often returns a proxy. So the correct question is not “did I get a reference?” but “have I already required business data from the related entity?” A relationship getter is not proof of SQL; it is only the point where Hibernate can place a stand-in.
Mistake #2: concluding that “the data is definitely in memory” because the field is not null.
Hibernate rarely leaves a lazy relationship as null, because null is a different business value: “there is no relationship.” A proxy is exactly what allows it to preserve the meaning of the relationship (“the customer exists and is linked”) while postponing the read of the data. So “not null” in ORM code does not yet mean “initialized.”
Mistake #3: being surprised by a “sudden SELECT” on the first access to a customer field.
As soon as you call getEmail(), getStatus(), getFirstName(), and any other “real” fields, Hibernate has to ensure data correctness. It cannot guess them. So it honestly issues SQL. This is not a “useless query”; it is the price you pay for choosing the lazy approach and deferring loading.
Mistake #4: using getReference() as if it were find() and expecting the data to already be inside.
People often take getReference() as a “fast find,” and then on the next line they read ref.getEmail() and wonder why a query was issued. The point of getReference() is precisely that it gives you a reference without reading. If on the very next line you read business fields, you have simply postponed SQL by one line, and that is normal. It is just important to understand what you are doing.
Mistake #5: not noticing that the IDE/debugger may “trigger” data access.
In the debugger, you sometimes want to expand an object and inspect its fields. Some display modes may indirectly touch getters or compute displayed values, and then you suddenly see SQL in the log “by itself.” In reality, you (more precisely, your debugger) asked for the data. In the Hibernate world, this is not mysticism: the first real access to the data initiates loading.
GO TO FULL VERSION