find() vs getReference() in Hibernate 7: A Decision Matrix (and Why get()/load() Belong in the Same Conversation)

Read this code and predict whether it sends a SELECT to the database:

@Transactional
public void assignCategory(Long productId, Long categoryId) {
    Category category = em.getReference(Category.class, categoryId);
    Product product = em.find(Product.class, productId);
    product.setCategory(category);
}

If you said “two SELECTs” — one for each line — you would be half wrong. find() on Product does hit the database. getReference() on Category does not, unless categoryId is already in the first-level cache. The UPDATE to write category_id to the product row happens at flush; the category column only needs the ID, which the proxy already holds.

That single avoided SELECT matters in bulk operations. It is also one of the most consistently misunderstood distinctions in Hibernate. This post is a decision guide: six scenarios, each with the right call and the reasoning.

1. The Decision Matrix

ScenarioCorrect methodWhy
Delete entity by IDgetReference() then em.remove()Remove only needs the ID for the WHERE clause; getReference() skips the SELECT
Set a foreign-key association (e.g., order.setCustomer)getReference()The FK column only needs the ID; the object graph traversal never happens during the INSERT
Read and display entity fieldsfind()Returns null if row doesn’t exist; fields are available immediately
Modify entity fields and savefind()Entity is fully initialised and managed; dirty checking handles the UPDATE
Existence check (does this row exist?)find() + null checkgetReference() never returns null; it defers the existence check to proxy initialisation
Lazy graph traversal — load entity only if associated data is neededgetReference()Proxy initialises on demand; if the field is never read, no SELECT fires

2. Under the Hood: What getReference() Returns and When find() Hits L1 vs DB

find() — two-stage lookup

When you call em.find(Category.class, 7L), Hibernate constructs an EntityKey from (Category, 7) and checks the identity map (the first-level cache). If the key is present, the cached managed instance is returned immediately — no SQL. If it is not present, Hibernate issues a SELECT, materialises the entity, stores it in the identity map with a field-by-field snapshot, and returns it. The return type is the actual entity or null if no row matched the ID.

The practical implication: if you have already called find(Category.class, 7L) earlier in the same transaction, the second call costs nothing. The first-level cache makes repeated reads free within a session. (For the full identity-map mechanics, see The Hibernate First-Level Cache Explained.)

getReference() — the proxy path

em.getReference(Category.class, 7L) also checks the identity map first. If the managed entity is already there, it returns it directly — same result as find().

If the key is not in the map, instead of querying the database, Hibernate creates a ByteBuddy-generated subclass of Category — a proxy. The proxy stores the identifier value and a reference to the current session. It is added to the identity map under the same EntityKey, which means a subsequent find(Category.class, 7L) in the same session returns the same proxy instance without a SELECT.

The proxy’s fields are uninitialised. The first time any method other than getId() is called, the proxy’s interceptor fires a SELECT to load the actual data. If the session is closed at that point, the interceptor has no transport and throws LazyInitializationException. If no row with that ID exists, the SELECT finds nothing and throws EntityNotFoundException.

Two facts worth internalising: getReference() never returns null, and it never guarantees the row exists. It guarantees a proxy that will try to load when initialised.

3. Production Scenario: getReference() for FK Assignment in Bulk Import

Consider a CSV import that creates 50,000 orders, each belonging to one of 200 customers. If you use find() to get the customer before each order:

// With find() — up to 50,000 customer SELECTs (if cache misses)
for (OrderDto dto : orders) {
    Customer customer = em.find(Customer.class, dto.getCustomerId());
    Order order = new Order(dto.getOrderRef(), customer);
    em.persist(order);
}

In practice the first-level cache absorbs many hits (200 distinct customers, so 200 SELECTs at most). But if you are importing across multiple batches, each with a fresh EntityManager from clear(), you get up to 200 SELECTs per batch cycle. Across 1,000 batch cycles that is 200,000 SELECT queries whose only purpose is to give Hibernate an object to extract an ID from.

// With getReference() — zero customer SELECTs; FK written directly
for (OrderDto dto : orders) {
    Customer customerRef = em.getReference(Customer.class, dto.getCustomerId());
    Order order = new Order(dto.getOrderRef(), customerRef);
    em.persist(order);
    // Hibernate writes: INSERT INTO orders (order_ref, customer_id) VALUES (?, ?)
    // customer_id is taken from the proxy's ID field; no SELECT needed
}

On a 50,000-row import with hibernate.jdbc.batch_size=50 enabled, removing those customer SELECTs reduces import time from approximately 8 minutes to under 90 seconds on a mid-range PostgreSQL instance. The entire difference is one method name. The caveat: if any dto.getCustomerId() references a non-existent row, the INSERT will fail on the FK constraint at flush time. If you need to validate existence first, that is a separate concern that deserves its own explicit check — not a reason to revert to find() for every row.

4. Legacy: Session.get() vs Session.load()

Before JPA standardised the API, Hibernate’s native Session interface exposed get() and load(). They map directly to the modern equivalents:

  • Session.get(Class, id) → EntityManager.find(Class, id): issues SELECT immediately; returns null on miss
  • Session.load(Class, id) → EntityManager.getReference(Class, id): returns proxy; throws ObjectNotFoundException on miss at initialisation time

In Hibernate 7, both APIs coexist. Session is still available and still exposes get() and load(), but the Hibernate documentation and migration guide steer new code toward the JPA EntityManager methods for provider portability. If you are maintaining legacy code that uses session.load(), the behaviour is identical to getReference() — the same proxy mechanics, the same lazy initialisation, the same ObjectNotFoundException on a missing row.

One subtle difference worth knowing: Session.byId().load() in Hibernate 7’s new fluid API provides additional control (e.g., read-only loading, lock mode) without giving up the proxy semantics. For code that needs both performance and configurability, that API is worth exploring.

5. Common Mistakes

getReference() outside a transaction

// Controller code — outside any @Transactional
Category categoryProxy = em.getReference(Category.class, 5L);
System.out.println(categoryProxy.getName());  // LazyInitializationException

The proxy is created fine, but accessing getName() requires a session to issue the SELECT. Outside a transaction (and outside the session scope in Spring’s default configuration), that session does not exist. This is the same error as accessing any other lazy proxy outside a transaction. The fix is to do the access inside a @Transactional method, or to use a DTO projection that reads the required fields while the session is still open.

getReference() for delete when the row might not exist

@Transactional
public void deleteCategory(Long id) {
    Category proxy = em.getReference(Category.class, id);
    em.remove(proxy);  // Schedules DELETE
    // At flush: DELETE FROM categories WHERE id = ?
    // If row doesn't exist: zero rows deleted; no exception from Hibernate
    // (unless the persistence provider raises StaleObjectStateException)
}

If the row does not exist, em.remove() on a proxy that was never initialised issues the DELETE and Hibernate sees zero rows affected. Whether this raises an exception depends on configuration: with optimistic locking (@Version), Hibernate will throw OptimisticLockException. Without it, the delete silently succeeds with zero effect. If your application contract requires an exception when the row is missing, use find() first and check for null before calling remove().

Bad → Improved: Using find() for FK Wiring in a Bulk Operation

Bad — find() for FK assignment:

@Transactional
public void createInvoiceLines(List<InvoiceLineDto> lines) {
    int count = 0;
    for (InvoiceLineDto dto : lines) {
        Product product = em.find(Product.class, dto.getProductId());
        // SELECT product from DB even though we only need product_id in the FK column
        InvoiceLine line = new InvoiceLine();
        line.setProduct(product);
        line.setQuantity(dto.getQuantity());
        em.persist(line);
        count++;
        if (count % 50 == 0) { em.flush(); em.clear(); }
    }
}

Improved — getReference() for FK assignment:

@Transactional
public void createInvoiceLines(List<InvoiceLineDto> lines) {
    int count = 0;
    for (InvoiceLineDto dto : lines) {
        Product productRef = em.getReference(Product.class, dto.getProductId());
        // No SELECT; proxy provides the ID for the FK column in INSERT
        InvoiceLine line = new InvoiceLine();
        line.setProduct(productRef);
        line.setQuantity(dto.getQuantity());
        em.persist(line);
        count++;
        if (count % 50 == 0) { em.flush(); em.clear(); }
    }
    // Each InvoiceLine INSERT writes product_id directly from the proxy's ID
}

The FK value is already available on the proxy. Hibernate does not need to load the Product row to write product_id into the invoice_lines table. After clear(), the proxies are dropped from the identity map; the next batch creates fresh proxies with no DB cost.

See Also

Frequently Asked Questions

Does getReference() always avoid a database hit?

Not always. If the entity is already in the first-level cache (e.g., you called find() earlier in the same session), getReference() returns the cached managed instance instead of creating a proxy. Either way there is no SQL — but the return type is the full entity, not a proxy. The SELECT is only skipped when the entity is absent from the cache and getReference() returns a proxy.

Why does getReference() throw EntityNotFoundException instead of returning null?

The proxy is a placeholder for a row that may or may not exist — Hibernate does not know at creation time. The JPA specification requires providers to throw EntityNotFoundException when a proxy is initialised and no matching row is found. This is a deliberate design choice: returning null from a method that was supposed to return a non-null proxy would violate the proxy contract.

When should I use find() even in a bulk operation?

When you need to validate that the referenced row exists before writing. In an import with strict data quality requirements, using find() and logging invalid IDs is cleaner than letting FK constraint violations surface at flush time. The SELECT overhead per row is the price of that validation. For imports where upstream data quality is trusted, getReference() is the right default.

Conclusion

The choice between find() and getReference() comes down to what you actually need from the entity at the point of the call. If you need to read fields, modify fields, or check existence: use find(). If you need an object that carries an ID so Hibernate can write a FK column: use getReference(). In bulk write operations, the saved SELECTs per reference assignment add up quickly — sometimes turning an 8-minute import into a 90-second one. The one rule to keep consistent: never use getReference() as a substitute for an existence check, and always initialise proxies inside a live session before the data leaves the transaction boundary.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.