Most developers who have used Hibernate for any length of time know the first-level cache exists. Ask them to describe it and you’ll hear something like: “It’s a cache Hibernate uses so it doesn’t hit the database twice for the same row.” That’s technically correct, but it misses almost everything that matters.
The first-level cache is not a feature you enable, a setting you tune, or an optional layer you bolt on for performance. It is the persistence context itself — always-on, transaction-scoped by default in Spring, and the thing that makes dirty checking, identity guarantees, and cascade operations possible. If you have ever used em.find(), you have used it. If you have ever hit an OutOfMemoryError in a batch job that loaded 200,000 entities, the first-level cache is why.
This post is a deep look at the mechanics: what the persistence context stores, when it is consulted, how EntityKey works, and the failure modes that catch experienced developers off guard.
1. What the First-Level Cache Actually Is
The first-level cache is the internal state of an EntityManager (or Hibernate Session). It is implemented as two parallel data structures:
- An identity map: a
Map<EntityKey, Object>that maps(EntityClass, primary key)pairs to managed Java instances. - A snapshot map: a parallel
Map<EntityKey, Object[]>that stores the field values of each managed entity at the moment it was loaded.
Together these two maps give Hibernate everything it needs for its three core guarantees: object identity, dirty detection, and write ordering. The term “first-level cache” is really a shorthand for the entire persistence context, not a separate caching subsystem.
The persistence context is scoped to the EntityManager instance. In a Spring Boot application with default configuration, that means one persistence context per @Transactional method call. The context opens when the method is entered, and it closes — along with every managed entity in it — when the method returns. There is no shared state between two separate @Transactional calls, no matter how quickly they execute back-to-back.
2. The Four Operations That Touch the First-Level Cache
find() — the happy path
em.find(Order.class, orderId) checks the identity map first. If an entry exists for (Order, orderId), Hibernate returns the same Java object reference that was stored when the entity was first loaded. No SQL runs. The object is reference-equal to anything else in the same session that holds a reference to the same entity — find(Order.class, 1L) == find(Order.class, 1L) is true within one session.
On a miss, Hibernate issues a SELECT, materialises the entity, puts it in the identity map, and stores a field-by-field snapshot. From that point forward, the entity is managed.
Repeated find() — the cache hit
@Transactional
public void process(Long orderId) {
Order firstRef = em.find(Order.class, orderId); // SELECT runs
Order secondRef = em.find(Order.class, orderId); // no SQL — identity map hit
System.out.println(firstRef == secondRef); // true
}
This is the behaviour that prevents the “chatty service” problem — a service that calls findById three times in the same transaction for the same row. Only the first call touches the database.
JPQL queries — the auto-flush trigger
JPQL queries do not check the identity map before going to the database. A SELECT u FROM User u WHERE u.status = 'ACTIVE' will always execute SQL. What is less obvious is that under FlushModeType.AUTO (the default), Hibernate will flush pending changes to any User entity before running that query, to ensure the database sees consistent data.
This means the SQL log can show an UPDATE immediately before a SELECT, with no explicit flush or commit in your code. It is not a bug — it is Hibernate protecting you from reading your own stale writes. But it surprises developers who expect writes to happen only at commit.
flush() — synchronising to the database
Flushing is the act of walking every managed entity in the identity map, comparing it to its snapshot, and issuing the corresponding SQL. Hibernate calls flush() automatically before commit (and before qualifying JPQL queries under AUTO mode). You can call it manually when you need a database trigger to fire, or when a downstream refresh() needs to see the result of a pending update.
Critically: flush() does not clear the identity map. The entities stay managed, their snapshots are updated to reflect the flushed state, and Hibernate continues tracking them. To remove them from the persistence context you need clear() or detach().
3. Under the Hood: EntityKey and the Snapshot Map
EntityKey is the internal record type Hibernate uses to key both maps. Its fields are the entity class and the identifier value. Two EntityKey instances are equal if and only if their class and identifier are equal — which is why Hibernate can guarantee one managed instance per row: it is structurally impossible for the same key to point to two different objects in a standard Java HashMap.
The snapshot stored alongside each entity is a shallow copy of the field values at load time. When dirty checking runs, Hibernate iterates every entry in the identity map. For each entity, it calls Objects.equals(snapshot[i], liveValue[i]) for every mapped field. Any mismatch marks the field as dirty and schedules it for inclusion in an UPDATE.
This means the snapshot equality semantics matter. For primitive types and immutable value types like String and LocalDate, equality is straightforward. For mutable container types — byte[], Map, or any custom AttributeConverter output — the comparison can produce false positives. A converter that re-serialises a HashMap to JSON on every comparison will almost certainly produce a different key-ordering on each call, meaning Hibernate sees the field as dirty on every flush even if no business code touched it. The result is phantom UPDATEs on every request — one of the more puzzling performance drains in production Hibernate deployments. (For a full worked example of diagnosing this, see The Hibernate 7 Persistence Context.)
4. When the First-Level Cache Helps
The identity map provides genuine performance benefits in a few specific patterns.
Read-modify-write in one transaction. Load an entity, call some setters, let the transaction commit. The snapshot comparison runs once at flush, generates one UPDATE regardless of how many setters you called, and the entity is gone when the session closes. This is the pattern Hibernate was designed for and executes efficiently.
Multiple reads of the same row. A service that loads the same parent entity in three different helper methods (permissions check, data mutation, audit log) benefits transparently. Only the first call hits the database; subsequent calls return the cached reference in microseconds.
Cascade resolution. When Hibernate walks an object graph to cascade a persist() or merge(), it uses the identity map to detect already-visited entities and avoid infinite loops. Without the map, cascading across bidirectional relationships would require explicit visited-set tracking in every caller.
5. When the First-Level Cache Hurts
The identity map is a liability in any scenario where the session lives longer than a single short request.
Long-running transactions accumulating entities. Every entity that enters the persistence context stays there until clear(), detach(), or session close. In a batch job that loads records in a loop, the identity map grows by one entry per record. At 50,000 records with an average entity size of 2 KB for the live object plus another 2 KB for the snapshot, that is 200 MB of heap dedicated to a Hibernate internal map — before any business objects. At 500,000 records, an OutOfMemoryError is a question of when, not if.
Flush slowdown. As the identity map grows, each flush must walk all entries and compare field by field. A session holding 10,000 entities each with 20 fields runs 200,000 equality comparisons on every flush. If JPQL queries trigger auto-flush five times per batch iteration, the flush cost can dominate the job’s runtime.
Native queries and stale state. A native SQL UPDATE or DELETE does not go through the persistence context. The identity map has no way of knowing the row changed. If you then call em.find() for an entity whose row was just modified by a native query, Hibernate returns the stale cached version, not the updated one. You need an explicit em.refresh(entity) to force a reload.
6. clear() and detach() Patterns for Batch Jobs
The standard pattern for long-running batch jobs is to flush and clear periodically, keeping the identity map small throughout the run.
@Transactional
public void importOrders(List<OrderDto> batch) {
int count = 0;
for (OrderDto dto : batch) {
Order order = toEntity(dto);
em.persist(order);
count++;
if (count % 50 == 0) {
em.flush(); // send pending SQL to the DB
em.clear(); // drop all managed entities from the identity map
}
}
}
Flush size of 50 is a starting point; tune it based on entity size and available heap. The important thing is that after clear(), all previously managed entities become detached. Do not hold references to them and expect subsequent mutations to persist — they will be silently ignored.
For batch jobs that only insert or update and do not need lifecycle callbacks or dirty checking at all, StatelessSession bypasses the identity map entirely:
try (StatelessSession statelessSession = sessionFactory.openStatelessSession()) {
Transaction tx = statelessSession.beginTransaction();
for (OrderDto dto : batch) {
statelessSession.insert(toEntity(dto));
}
tx.commit();
}
With StatelessSession, no snapshots are taken, no dirty checking runs, no identity map grows. The throughput improvement on large inserts can be dramatic — roughly the difference between inserting 1,000 rows/second (full session with clear) and 15,000–30,000 rows/second (stateless, with JDBC batch enabled). For the JDBC batch settings that unlock those numbers, see Batch Processing with Hibernate 7.
7. L1 vs L2: What Each Is For
The first-level cache and the second-level cache are often mentioned together but solve fundamentally different problems.
| Dimension | First-Level Cache (L1) | Second-Level Cache (L2) |
|---|---|---|
| Scope | One EntityManager / Session | SessionFactory — shared across all sessions |
| Mandatory | Yes, always on | No, opt-in per entity |
| Survives session close | No | Yes |
| Concurrency | Single thread; no contention | Multi-threaded; requires cache concurrency strategy |
| Main purpose | Object identity + dirty checking within a transaction | Reduce DB round-trips across transactions |
| Staleness risk | Only within the same session (controllable) | Across sessions — invalidation is your problem |
L2 makes sense for read-heavy reference data (countries, currencies, permission sets) that rarely changes and is accessed by many concurrent users. Enabling L2 on a high-write entity adds cache invalidation overhead without meaningful read savings. For the full configuration walkthrough and the three staleness modes that bite in production, see Hibernate 7 Second-Level Cache.
Bad → Improved: Long-Running Batch Without clear()
This is the most common first-level cache failure in production batch jobs.
Bad — identity map grows without bound:
@Transactional
public void recalculatePricing(List<Long> productIds) {
for (Long productId : productIds) {
Product product = em.find(Product.class, productId);
product.setPrice(pricingEngine.calculate(product));
// no flush, no clear — all 200,000 products accumulate in the identity map
// heap grows, flush at commit walks 200,000 entities
// result: OOM at ~80,000 products; if it survives, commit takes 40 seconds
}
}
Improved — periodic flush and clear:
@Transactional
public void recalculatePricing(List<Long> productIds) {
int count = 0;
for (Long productId : productIds) {
Product product = em.find(Product.class, productId);
product.setPrice(pricingEngine.calculate(product));
count++;
if (count % 100 == 0) {
em.flush(); // write current batch to DB inside the same transaction
em.clear(); // drop all managed entities — identity map size stays at ~100
}
}
// final flush on commit handles any remainder
}
With clear-every-100, the identity map never holds more than 100 entities. Flush cost per iteration is constant. Heap stays flat. The entire 200,000-product job completes in roughly the time the bad version would have spent just on the final commit flush.
See Also
- 📘 The Hibernate 7 Persistence Context — entity states, dirty checking internals, phantom UPDATE walkthrough
- 📘 Hibernate 7 Second-Level Cache — cross-session caching with Ehcache 3, staleness modes
- 📘 Batch Processing with Hibernate 7 — JDBC batch settings, StatelessSession, flush/clear cadence
- 📘 Merging vs. Refreshing Entities — when to call refresh() after native queries that bypass L1
- 📘 Lazy Loading in Hibernate 7 — how the identity map interacts with proxy initialisation
Frequently Asked Questions
Can you disable the first-level cache?
No. The identity map is built into the EntityManager contract. Disabling it would break dirty checking, cascade operations, and the object-identity guarantee. If you need to bypass it for bulk work, use StatelessSession — that is the designed escape hatch.
Does flush() clear the cache?
No. flush() synchronises the identity map’s state with the database — it writes pending SQL — but leaves all managed entities in the map. After a flush, entities are still managed and will be dirty-checked again at the next flush. To remove entities from the map, call clear() (all entities) or detach(entity) (one entity).
Why does my JPQL query see a stale entity?
If a native SQL statement updated a row outside of Hibernate’s awareness, the identity map still holds the pre-update snapshot. JPQL queries return entities from the identity map when the key matches — they do not re-read columns that are already cached. Call em.refresh(entity) after any native DML that touches a row you have cached in the current session.
How large can the first-level cache get?
As large as available heap allows — there is no built-in size limit. In a typical web request that touches 10–50 entities, this is not a concern. In a batch job touching 100,000+ entities without periodic clear(), it will cause an OutOfMemoryError. Monitor heap and flush-time duration in batch workloads; both are symptoms of an oversized identity map.
Conclusion
The first-level cache is not a performance feature — it is the internal data structure that makes Hibernate coherent. The identity map guarantees that one row maps to one Java object; the snapshot map makes dirty checking work without any annotation on your setters. For short-lived web requests, all of this happens transparently and correctly. For long-running sessions and batch jobs, it becomes a resource liability the moment you stop managing it: the map grows without bound, flush cost scales with its size, and an OOM eventually follows. The fix is mechanical — periodic flush() and clear() for jobs that must use a full session, or StatelessSession for jobs that don’t need the lifecycle machinery at all. Once you understand what the two maps contain and when they are consulted, the first-level cache stops being a mystery and starts being a predictable, controllable part of your Hibernate toolset.