Skip to main content

Hibernate 7 Natural IDs: What L1 and L2 Caching Actually Give You, Measured

Verified against Hibernate 7.4.5: a repeat bySimpleNaturalId lookup in the same session costs zero extra queries with no cache at all, but the bigger correction is that @NaturalIdCache populates its L2 region at insert time, not on first lookup — so the textbook miss-then-hit story only applies to rows Hibernate didn’t insert itself.

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, with hibernate-jcache + Ehcache 3.10.8 for the L2 measurements. Every query count and log line below comes from SessionFactory.getStatistics() or a captured boot log, not from documentation.
PartBest suited for
Part 1 — Beginnerwhat @NaturalId is for, and what it actually saves you with no L2 cache at all
Part 2 — Intermediateturning on L2 caching, the insert-time population surprise, and reading the API surface correctly
Part 3 — Advancedcomposite 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. Putting hibernate-jcache on the classpath at all — regardless of whether you configure anything — makes Hibernate 7.4.5 resolve a RegionFactory through the service loader and silently turn the second-level cache on for every SessionFactory in the application, logging only HHH90001028. This broke this repo’s own unrelated test suite before hibernate.cache.use_second_level_cache: false was pinned explicitly in application.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.

When does the NaturalIdCache entry actually appear? Row inserted via Hibernate persist() commit() cache PUT=1 lookup #1 HIT, 0 queries lookup #2 (new session) HIT, 0 queries Row inserted via raw JDBC (bypasses Hibernate) INSERT (no cache PUT) lookup #1 MISS, 1 query, PUT lookup #2 (new session) HIT, 0 new queries Most rows in a real app are inserted through Hibernate — so the “miss-then-hit” story is the uncommon path, not the default one.

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/DELETE queries 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

No Comments yet!

Leave a Reply

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