You add @NaturalId and @NaturalIdCache to an entity expecting the textbook cache story: first lookup misses and populates the cache, second lookup hits. Then you check the statistics on a freshly inserted row and the miss never happened — the cache was already populated before anyone looked anything up. That’s not a bug in your setup; it’s how @NaturalIdCache actually works, and it changes what “warm-up” means for the common case of rows your own application inserted.
Verified against: Hibernate ORM 7.4.5.Final, Spring Boot 4.1.1, Jakarta Persistence 3.2.0, Java 25, H2 2.4.240, withhibernate-jcache+ Ehcache 3.10.8 for the L2 measurements. Every query count and log line below comes fromSessionFactory.getStatistics()or a captured boot log, not from documentation.
| Part | Best suited for |
|---|---|
| Part 1 — Beginner | what @NaturalId is for, and what it actually saves you with no L2 cache at all |
| Part 2 — Intermediate | turning on L2 caching, the insert-time population surprise, and reading the API surface correctly |
| Part 3 — Advanced | composite keys, mutability enforcement, and the equals/hashCode dedup trap unique to natural-id equality |
Part 1 — What a natural id actually buys you before any cache is involved
Most entities get a surrogate id because it’s easy, but business logic and APIs rarely address anything by Customer #5429 — they use [email protected], or a SKU, or an ISBN. Query by that field the ordinary way and Hibernate treats it like any other criteria: even if the row is already sitting in the current session, a plain JPQL query still round-trips to the database to validate the result against the underlying table. @NaturalId exists to give a business key the same identity-resolution treatment a primary key gets.
import jakarta.persistence.*;
import org.hibernate.annotations.NaturalId;
@Entity
public class NaturalIdProduct {
@Id
@GeneratedValue
private Long id;
@NaturalId
@Column(nullable = false, unique = true, updatable = false)
private String sku;
private String name;
}
One nuance worth stating plainly and not re-testing here: even with Hibernate’s query cache enabled, a cached JPQL/HQL result still isn’t a free identity-based lookup the way a natural-id resolution is — the query cache validates its cached result set against an update-timestamps region before trusting it. That’s standard, documented Hibernate query-cache behavior, not a new finding from this repo.
By default a natural id is immutable — Hibernate enforces this itself, not just as a convention (Part 3 shows what happens if you violate it). The lookup uses bySimpleNaturalId for the common single-field case:
Product p = session.bySimpleNaturalId(Product.class).load("SKU-123");
The claim worth testing first: does this save anything at all with no second-level cache configured anywhere — no @NaturalIdCache, no @Cache, no L2 provider on the classpath? Yes, within a single session. Two calls to bySimpleNaturalId for the same value, same session:
RESULT[naturalid-l1-no-l2]: queries after 1st bySimpleNaturalId=1, after 2nd (same session)=1 (no @NaturalIdCache, no L2 cache provider configured)
One query total, not two, and the second call returns the identical object reference — Hibernate’s persistence context keeps its own natural-id-to-PK map for the life of the session. That is genuinely better than a repeat JPQL query, which always re-validates against the table. The scope is exactly as narrow as it sounds, though: a brand-new session repeating the identical lookup re-fires the query, cumulative count going from 1 to 2:
RESULT[naturalid-cross-session-no-l2]: queries after session 1 lookup=1, cumulative after a NEW session repeats the same lookup=2 (no L2 cache -- the L1 natural-id map dies with the session)
So the honest Part 1 summary: @NaturalId alone, no cache configuration, saves you nothing across requests in a typical web app where each request gets its own session — it only helps when you look the same value up more than once inside one unit of work. Cross-request savings require a second-level cache, which is where Part 2 goes next.
Part 2 — Turning on L2, and the surprise in how it actually warms up
Getting real L2 numbers meant adding hibernate-jcache and Ehcache to the build — not warm in a typical sandbox, and not without two small traps of their own: hibernate-jcache correctly pulls javax.cache:cache-api rather than a jakarta.cache artifact (JSR-107 was never migrated to the Jakarta namespace), and plain org.ehcache:ehcache drags in a dead jaxb-runtime dependency chain through Maven Central’s maven.java.net mirror — the jakarta classifier plus excluding jaxb-runtime sidesteps both.
A much bigger trap ships in that same dependency. Puttinghibernate-jcacheon the classpath at all — regardless of whether you configure anything — makes Hibernate 7.4.5 resolve aRegionFactorythrough the service loader and silently turn the second-level cache on for everySessionFactoryin the application, logging onlyHHH90001028. This broke this repo’s own unrelated test suite beforehibernate.cache.use_second_level_cache: falsewas pinned explicitly inapplication.yml. See chapter 09 for the full incident. If your app has this dependency for any reason, check whether you actually meant to enable L2 caching everywhere.
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@NaturalIdCache
public class CachedNaturalIdProduct {
@Id @GeneratedValue
private Long id;
@NaturalId
@Column(nullable = false, unique = true, updatable = false)
private String sku;
private String name;
}
The natural mental model for any L2 cache is “miss once per cold value, hit from then on.” That model is only half right for @NaturalIdCache. Measuring immediately after persist() + commit(), before anyone has looked the row up at all:
RESULT[naturalid-l2-cache]: immediately after persist()+commit(): queries=2, naturalId cache put=1, naturalId cache hit=0 -- @NaturalIdCache populates the L2 region on INSERT, before anyone ever looked it up.
RESULT[naturalid-l2-cache]: session1 (post-insert) cumulative queries=0 | session2 (new session, same natural id) cumulative queries=0, naturalId cache hits=2
Both the “first” lookup in the same process and a genuinely new-session lookup resolve with zero additional queries and register as cache hits. If your rows are inserted through Hibernate itself — the common case — there is no miss to observe; the cache is already warm the moment the transaction commits. The classic miss-then-hit story is real, but only for a row Hibernate didn’t insert — one written by raw JDBC, bypassing Hibernate’s persist-time cache population entirely:
RESULT[naturalid-l2-cache-real-miss-then-hit]: first lookup (genuine cold row) queries=1, naturalId miss=1, naturalId put=1 | second lookup (new session) cumulative queries=1, naturalId hits=1
That is the important correction to make to the standard “cache warms on first miss” framing: for entities inserted through Hibernate, which is most of them, the miss-then-hit path never happens at all. The put happens at insert time.
The API surface is also bigger than a single method. javap on org.hibernate.Session confirms three natural-id entry points, not the two most examples show:
$ javap -cp <hibernate-core-7.4.5.Final> org.hibernate.Session | grep -i naturalid
public abstract <T> org.hibernate.NaturalIdLoadAccess<T> byNaturalId(java.lang.Class<T>);
public abstract <T> org.hibernate.NaturalIdLoadAccess<T> byNaturalId(java.lang.String);
public abstract <T> org.hibernate.SimpleNaturalIdLoadAccess<T> bySimpleNaturalId(java.lang.Class<T>);
public abstract <T> org.hibernate.SimpleNaturalIdLoadAccess<T> bySimpleNaturalId(java.lang.String);
public abstract <T> org.hibernate.NaturalIdMultiLoadAccess<T> byMultipleNaturalId(java.lang.Class<T>);
public abstract <T> org.hibernate.NaturalIdMultiLoadAccess<T> byMultipleNaturalId(java.lang.String);
bySimpleNaturalId is the ergonomic form for a single-field natural id (session.bySimpleNaturalId(Product.class).load("SKU-123") instead of the more verbose .byNaturalId(...).using("sku", ...).load()), and byMultipleNaturalId resolves a batch in one round trip:
List<String> skus = List.of("SKU-1", "SKU-2", "SKU-3");
List<Product> products = session.byMultipleNaturalId(Product.class).multiLoad(skus);
Part 3 — Composite keys, enforced mutability, and a dedup trap of its own
A natural id doesn’t have to be one field. A Department unique within a Company is a genuine two-field business key:
@Entity
public class Department {
@Id @GeneratedValue private Long id;
@NaturalId
@ManyToOne
@JoinColumn(name = "company_id")
private Company company;
@NaturalId
private String deptCode;
}
Department dept = session.byNaturalId(Department.class)
.using("company", acme)
.using("deptCode", "ENG-01")
.load();
This resolves in exactly one query, filtering on the foreign-key column directly rather than joining into Company’s own identity:
select d1_0.id,c1_0.id,c1_0.name,d1_0.dept_code,d1_0.name from department d1_0 left join company c1_0 on c1_0.id=d1_0.company_id where d1_0.company_id=? and d1_0.dept_code=?
Immutability isn’t just a recommendation Hibernate leaves you to enforce yourself. Mutating a @NaturalId field left at its default (mutable = false) and flushing throws, with no @Column(updatable = false) guard needed anywhere — this is Hibernate’s own natural-id consistency check:
RESULT[naturalid-immutable-mutation]: flushing a changed IMMUTABLE natural id threw: org.hibernate.HibernateException: An immutable natural identifier of entity com.ankurm.hibernatedemo.naturalid.ImmutableNaturalIdEntity was altered from `CODE-A` to `CODE-B`
@NaturalId(mutable = true) is the explicit opt-in for a value that can legitimately change (an email address, say). It flushes cleanly, and a lookup by the old value correctly returns null while the new value resolves to the same row — no stale entry survives the mutation:
RESULT[naturalid-mutable-mutation]: flush succeeded, DB column now = CODE-Y
RESULT[naturalid-mutable-stale-lookup]: byNaturalId("CODE-OLD") = null, byNaturalId("CODE-NEW") = 2
One detail from the original example worth keeping verbatim: comparing with Hibernate.getClass(this) != Hibernate.getClass(o) rather than this.getClass() != o.getClass() matters once a Hibernate proxy is involved — a lazy-loaded reference’s runtime class is a Hibernate-generated proxy subclass, not the entity class itself, so a plain getClass() comparison can wrongly reject two equal entities:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null) return false;
if (Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
NaturalIdEqualsEntity that = (NaturalIdEqualsEntity) o;
return Objects.equals(sku, that.sku);
}
@Override
public int hashCode() {
return Objects.hash(sku);
}
The recommendation to base equals()/hashCode() on the natural id rather than the surrogate id (see the equals/hashCode HashSet trap for the surrogate-id failure this avoids) genuinely holds up — a natural id set at construction time never changes hash bucket on persist():
RESULT[naturalid-equals-hashset]: after persist(), e.getId()=1, e.getSku()=SKU-EQ-1, set.contains(e) = true (equals/hashCode based on the immutable natural id, NOT the surrogate id)
There is one nuance the standard advice doesn’t mention: two different, never-persisted objects that happen to share the same natural id value are already equals() to each other, before either one reaches the database.
RESULT[naturalid-equals-transient-dup]: a.equals(b)=true for two transient instances sharing sku='SKU-EQ-DUP' but different names; HashSet.add(b) rejected it as a duplicate = true
That’s usually correct behavior — natural ids are supposed to be unique business keys — but it means natural-id equality can silently swallow two objects that were meant to be different but were accidentally constructed with the same business key before either ever touched a session. A surrogate-id approach cannot produce this particular bug, because two transient surrogate-id objects are never accidentally “equal” unless you specifically wrote id-based equality, which is the pattern this whole article and its sibling both steer you away from.
Two more edge cases worth a line each
- Bulk
UPDATE/DELETEqueries bypass the persistence context entirely — a well-established Hibernate behavior, not specific to natural ids, but worth restating here: it can leave an L1 or L2 natural-id cache entry pointing at a row that no longer matches. This repo didn’t construct a dedicated reproduction of the stale-cache window, so treat the mechanism as confirmed Hibernate behavior and the exact staleness window as something to verify against your own cache-eviction settings. - Prefer routing business logic through the natural id, not the surrogate id, once you’ve paid for a cache lookup on it — it keeps the surrogate id as an internal implementation detail rather than something service-layer code depends on directly. This is a design preference, not a measured behavior.
FAQ
Does @NaturalId create a unique constraint automatically? No. Back it explicitly with @Column(unique = true, nullable = false) and a real database constraint. Hibernate’s resolution logic assumes uniqueness; it does not enforce it.
What happens if two rows share a natural id value? Undefined, and likely to surface as a mismatched or exception-throwing lookup rather than a clean error at insert time — this repo didn’t reproduce that specific failure mode, so treat it as a reason to enforce the database constraint, not a documented behavior.
Can I use @NaturalId with Spring Data JPA? Yes, though Spring Data repositories don’t expose natural-id lookup methods directly. The standard approach is unwrapping the Hibernate Session from the injected EntityManager inside a custom repository method: entityManager.unwrap(Session.class).bySimpleNaturalId(Product.class).load(sku). This is a mechanical API fact, not something this rewrite ran a dedicated test against.
Can @NaturalId span multiple fields? Yes — see the composite Department example above. The L1 and L2 caches key on the full combination.
Should you reach for @NaturalId at all?
If you look up an entity by a business key more than once in the same request, @NaturalId alone earns its keep with zero extra configuration — that’s the L1 story from Part 1, and it’s free. Don’t add @NaturalIdCache and a whole L2 provider on the strength of “cross-session performance” alone; it drags in a dependency that silently flips on second-level caching for your entire application (Part 2), which is a bigger architectural decision than a single entity’s lookup pattern should be making for you. Reach for it deliberately, measure it the way this article did, and keep the natural id genuinely immutable — Hibernate will enforce that last part whether you meant to rely on it or not.
Further reading
- Repo: Chapter 06 — Natural IDs, with the full test suite behind every claim above
- Repo: Chapter 09 — the hibernate-jcache classpath-pollution incident in full
- JPA persistence annotations in Hibernate 7 —
@NaturalIdalongside every other entity mapping annotation, and the surrogate-id equals/hashCode trap it avoids - Hibernate 7 second-level cache — the L2 provider setup this article measured against
- Hibernate 7 @Immutable entities — a complementary read-only performance pattern
- Entity equality across sessions — implementing equals()/hashCode() safely in more depth
- Official Hibernate 7 User Guide — Natural IDs
No Comments yet!