Sitemap
devdomain

DevDomain explores software development for insights, guides, and the latest industry news in helping all code-level coders. Delve deep into the newest trends in tech, level up your skills with expert advice, and discuss them with the rest of the community.

Hibernate Caching Explained: First-Level, Second-Level, and Query Cache

9 min readSep 10, 2026

--

Caching is one of those features that feels free until it isn’t. You flip a flag, your read-heavy endpoint gets faster, everyone celebrates — and three weeks later someone files a bug because a user’s profile shows a stale email address that “definitely changed yesterday.”

Hibernate ships with three distinct caching layers, and they solve different problems with different lifetimes, different scopes, and very different failure modes. If you treat them as one big “cache” switch you will eventually get burned. This article unpacks all three: the first-level (persistence-context) cache that you already use whether you know it or not, the optional second-level cache and its providers, and the query cache that almost everyone misconfigures.

The three caches at a glance

Cache Scope Lifetime Default Stores First-level (L1) Single EntityManager/Session One persistence context (usually one transaction) Always on, cannot disable Managed entity instances Second-level (L2) EntityManagerFactory (whole app, per JVM) Until evicted/expired Off Entity state (dehydrated), collections Query cache EntityManagerFactory Until evicted/expired Off Query result identifiers

The key mental model: L1 caches entity object references; L2 caches dehydrated entity state; the query cache caches the IDs a query returned, not the entities themselves.

First-level cache: the persistence context

The L1 cache is the persistence context. Every EntityManager (Hibernate Session) maintains a map of every managed entity it has loaded or persisted, keyed by entity type and primary key. You don't configure it, you can't turn it off, and it is scoped to a single persistence context — typically a single transaction.

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductService {
@PersistenceContext
private EntityManager em;
@Transactional
public void demonstrateL1() {
Product p1 = em.find(Product.class, 1L); // SELECT hits the database
Product p2 = em.find(Product.class, 1L); // no SQL — served from L1
// Same managed instance, guaranteed identity within the context:
assert p1 == p2;
}
}

The second find issues no SQL. More importantly, p1 == p2 is true: within one persistence context Hibernate guarantees a single in-memory instance per database row. This is the identity guarantee, and it is why dirty checking and lazy loading work at all.

Why L1 sometimes “lies”

Because L1 is bound to the persistence context, two transactions see two separate caches. This is correct, but it surprises people:

@Transactional
public void update(Long id, String name) {
Product p = em.find(Product.class, id);
p.setName(name);
// Auto dirty-checked and flushed at commit — `no explicit save needed.
}

If another thread loaded the same product into its persistence context before your commit, that thread still sees the old name. That’s not a cache bug — it’s transaction isolation. L1 never crosses transaction boundaries, so it cannot serve stale data across requests. The dangerous one is the next layer.

Second-level cache: shared across the application

The L2 cache lives on the EntityManagerFactory and is shared by every session in the JVM. It survives transactions. When enabled and an entity is marked cacheable, Hibernate stores the entity's dehydrated state (a flat array of column values, not the object graph) in a region, keyed by ID.

L2 stores state, not instances. When you read a cached entity, Hibernate rehydrates a fresh object from the stored array. That’s why L2 entries can be shared safely across threads — “nobody hands out a mutable shared object.

Enabling L2 in Spring Boot 3.x

You need a provider on the classpath plus configuration. Hibernate uses JCache (JSR-107) as the standard bridge, with EhCache 3 or Caffeine as common backends.

<!-- pom.xml -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.10.8</version>
<classifier>jakarta</classifier>
</dependency>
# application.properties
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=jcache
spring.jpa.properties.hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider
spring.jpa.properties.hibernate.javax.cache.uri=classpath:ehcache.xml
# Surface what gets cached during development:
spring.jpa.properties.hibernate.generate_statistics=true

Note the jakarta classifier on EhCache 3.10+— without it you'll pull the javax-namespaced jars and Hibernate 6 (Jakarta Persistence) will not wire up.

Marking entities cacheable

L2 is opt-in per entity. Use Jakarta’s @Cacheable plus Hibernate's @Cache to pick a concurrency strategy:

import jakarta.persistence.Cacheable;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "products")
public class Product {
@Id
private Long id;
private String name;
private java.math.BigDecimal price;
// getters/setters
}

For collections, you must annotate the association itself — caching the owning entity does not cache its collections:

@OneToMany(mappedBy = "product")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "product.reviews")
private List<Review> reviews = new ArrayList<>();

Concurrency strategies

The strategy controls how Hibernate keeps the cache coherent with the database under concurrent writes.

Strategy Use when Behavior READ_ONLY Reference data that never changes Fastest; throws if you try to update NONSTRICT_READ_WRITE Rare writes, brief staleness OK Evicts (not updates) on commit; small stale window READ_WRITE Frequent writes, need consistency Uses soft locks during write; safe but more overhead TRANSACTIONAL JTA environments with XA Cache participates in the transaction

For most Spring Boot apps on a single datasource, READ_WRITE is the safe default and READ_ONLY is ideal for lookup tables (countries, currencies, config).

A minimal ehcache.xml

<config xmlns='http://www.ehcache.org/v3'>
<cache alias="products">
<expiry><ttl unit="minutes">30</ttl></expiry>
<heap unit="entries">10000</heap>
</cache>
<cache alias="default-query-results-region">
<expiry><ttl unit="minutes">5</ttl></expiry>
<heap unit="entries">2000</heap>
</cache>
</config>

Using Caffeine or Redis instead

Caffeine is a drop-in JCache provider for a fast local cache — swap the provider class and dependency:

spring.jpa.properties.hibernate.javax.cache.provider=com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider

Redis gives you a distributed L2 so multiple app instances share (and invalidate) the same cache. The common choice is Redisson, which ships a Hibernate region factory:

spring.jpa.properties.hibernate.cache.region.factory_class=org.redisson.hibernate.RedissonRegionFactory
spring.jpa.properties.hibernate.cache.redisson.config=classpath:redisson.yaml

The trade-off is fundamental: local caches (EhCache/Caffeine) are faster but go stale across nodes unless every write evicts on every node; distributed caches (Redis) stay coherent but add a network hop on every cache hit. Don’t reach for Redis L2 until you’ve proven a local cache causes cross-node staleness problems.

The query cache

The query cache solves a problem L2 doesn’t: caching the results of a query. But it does so in a subtle way — it stores only the primary keys the query returned, plus a timestamp. The entities themselves are then fetched from L2 (or the database if not cached there).

Get Marcelo Domingues’s stories in your inbox

Join Medium for free to get updates from this writer.

This means the query cache is nearly useless on its own. If you cache a query but the matched entities aren’t in L2, Hibernate re-fetches every entity by ID — sometimes turning one query into N. Always enable L2 for the entities a cached query returns.

spring.jpa.properties.hibernate.cache.use_query_cache=true
import org.hibernate.jpa.AvailableHints;
import jakarta.persistence.TypedQuery;
public List<Product> findActive() {
TypedQuery<Product> q = em.createQuery(
"select p from Product p where p.active = true", Product.class);
q.setHint(AvailableHints.HINT_CACHEABLE, true); // opt-in per query
return q.getResultList();
}

Query cache invalidation and the timestamp region

Hibernate maintains a special UpdateTimestampsCache region. Every time a table is modified, Hibernate records the timestamp of the last update for that table. When it considers serving a cached query result, it checks: was any queried table modified after this result was cached? If so, the cached result is discarded.

This makes the query cache automatically invalidated on writes to the affected tables — which is correct, but also means a write-heavy table will constantly bust its query cache, giving you all the overhead and none of the benefit. The query cache pays off only for queries over tables that change rarely relative to how often the query runs.

Invalidation: how each layer stays fresh

Event L1 L2 Query cache Entity updated via Hibernate Updated in context Region entry updated/evicted Table timestamp bumped → results busted em.clear() / context closed Cleared Untouched Untouched Native SQL UPDATE/DELETE Not aware Stale! Stale unless you tell Hibernate TTL expiry (provider) n/a Entry evicted Entry evicted

The killer is the third row. Hibernate only invalidates L2 for changes it performs through the ORM. Native queries, JDBC, Flyway/Liquibase migrations, and direct DB edits bypass L2 entirely, leaving stale entries behind. If you run native bulk updates, tell Hibernate which spaces (tables) they touch:

import org.hibernate.query.NativeQuery;
NativeQuery<?> q = em.createNativeQuery("UPDATE product SET price = price * 1.1")
.unwrap(NativeQuery.class);
q.addSynchronizedEntityClass(Product.class); // invalidate Product's L2 region
q.executeUpdate();

You can also evict programmatically through the JPA Cache API:

import org.springframework.beans.factory.annotation.Autowired;
import jakarta.persistence.EntityManagerFactory;
@Autowired
EntityManagerFactory emf;
public void evict() {
emf.getCache().evict(Product.class, 1L); // one entity
emf.getCache().evict(Product.class); // whole Product region
emf.getCache().evictAll(); // nuke everything
}

How L2 stores data: hydration and dehydration

It’s worth dwelling on what L2 actually keeps, because it explains several behaviors that otherwise look arbitrary.

When Hibernate puts an entity into L2, it does not store your object. It stores a dehydrated form: a flat Object[] of the entity's column values, indexed by property, plus the version and the discriminator if applicable. Associations are stored as foreign-key identifiers, not as resolved object graphs.

When you read from L2, Hibernate hydrates a brand-new entity instance from that array, associates it with your current persistence context, and hands it back. Two important results follow:

  • L2 entries are immutable snapshots of state. Two threads reading the same cached entity each get their own fresh instance — there’s no shared mutable object to corrupt. This is what makes L2 thread-safe.
  • Lazy associations are not “pre-loaded” by L2. A cached entity’s @OneToMany is still a lazy proxy unless that collection has its own cached region. L2 caching the parent does nothing for the children.

This dehydration model is also why L2 cannot help with arbitrary query results — a query returns rows that may not map one-to-one to a cacheable entity. That’s the gap the query cache fills, and the reason it stores IDs rather than state.

Putting it together: a worked configuration

Here’s a coherent setup for a catalog service: products are read constantly and updated occasionally, categories are effectively static, and a “find active products” query runs on every page load.

@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "products")
public class Product { /* ... */ }
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY, region = "categories")
public class Category { /* ... never changes at runtime */ }
spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.use_query_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=jcache
spring.jpa.properties.hibernate.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider
spring.jpa.properties.hibernate.javax.cache.uri=classpath:ehcache.xml

Category uses READ_ONLY (cheapest, no soft-locking) because it's immutable at runtime. Product uses READ_WRITE because it's updated. The active-products query is cacheable and is backed by the cached Product region, so a cache hit returns IDs and then resolves them straight from L2 — no database round trip at all on the happy path. When any product is written, the timestamp region busts the query result and the next page load rebuilds it.

When caching helps — and when it hurts

L2 and the query cache pay off when reads massively outnumber writes and the data is small enough to fit comfortably in memory: reference tables, configuration, product catalogs, rarely-changing aggregates. The hit ratio is what matters; check it with statistics:

import org.hibernate.stat.Statistics;
Statistics stats = emf.unwrap(org.hibernate.SessionFactory.class).getStatistics();
long hits = stats.getSecondLevelCacheHitCount();
long misses = stats.getSecondLevelCacheMissCount();
double ratio = (double) hits / (hits + misses);

A hit ratio below ~80% usually means the cache costs more than it saves: you pay the put/evict overhead on writes and the memory footprint without recovering it on reads.

Caching hurts when:

  • Writes are frequent. Every write evicts/updates entries and busts query results. You add overhead and gain little.
  • Data must be strictly fresh (prices, inventory, balances). Even a 5-minute TTL is a 5-minute window for wrong answers.
  • You run multiple nodes with a local L2. Node A’s write doesn’t evict Node B’s cache. You now have per-node stale data that’s maddening to reproduce.
  • Cardinality is huge. Caching millions of rarely-reread rows just evicts useful entries.

Common pitfalls

  • Enabling the query cache without L2. The query cache stores IDs; without L2 each cached query re-hydrates every row by ID — often slower than no cache.
  • Forgetting @Cache on collections. Caching the parent entity does not cache its @OneToMany/@ManyToMany. The association needs its own @Cache.
  • Native/bulk writes silently going stale. em.createQuery("delete ...").executeUpdate() (JPQL bulk) and native SQL bypass L1 and, for native, L2 unless you add synchronized spaces. Always evict or synchronize.
  • Mixing the javax and jakarta EhCache jars in Hibernate 6 — use the jakarta classifier or(the cache region factory silently won't initialize.
  • Caching mutable reference data as READ_ONLY. If it ever changes, you'll get an exception on write or a permanently stale value. Use READ_WRITE if updates are possible.
  • Local L2 in a clustered deployment. This is the single most common stale-data incident. Use Redis (Redisson) or accept TTL-bounded staleness deliberately.
  • No TTL / unbounded heap. Without expiry and size limits you cache forever and risk OutOfMemoryError.
  • Trusting demo numbers. L1 makes a single transaction look cache-friendly. Real gains come only from cross-transaction L2 with a measured high hit ratio.

Wrapping up

Hibernate’s three caches are not interchangeable. L1 is always there, scoped to your transaction, and gives you the identity guarantee that makes the ORM work. L2 is an opt-in, JVM-wide store of dehydrated state that pays off for read-heavy reference data — if you pick the right concurrency strategy and respect invalidation. The query cache is a narrow tool that only helps over rarely-changing tables and only when L2 backs it up.

Measure your hit ratios, be honest about your read/write mix, and never assume a write you made outside the ORM updated the cache. Used deliberately, caching is a scalpel. Used as a magic flag, it’s a stale-data generator.

If this saved you a production incident, follow devdomain for more deep-dives on Spring Boot, JPA, and Hibernate internals. Got a caching war story or a config that bit you? Drop it in the comments — I read and reply to every one.

--

--