Hibernate @NaturalId: Natural Ids, Lookups and Caching

Map a natural id such as a license plate with Hibernate’s @NaturalId, load entities with em.find(…, KeyType.NATURAL), handle mutable and composite natural ids, and cache lookups with @NaturalIdCache, with the SQL and statement counts from Hibernate 7.4.

logo

A natural id is a field that identifies an entity by real-world data, such as the license plate of a vehicle, while the primary key stays a generated number. In Hibernate, we mark that field with @NaturalId. Hibernate adds a unique constraint for it and loads entities by its value with find(…, KeyType.NATURAL). It also remembers which plate belongs to which id, so a repeated lookup runs no SQL.

We use a natural id when users search by a business value instead of the database id, such as a parking app that looks up a car by the plate a camera reads at the gate.

The following example maps the license plate as a natural id and loads a vehicle by its plate, with the SQL of each call as a comment.

@Id
@GeneratedValue
private Long id;                  // surrogate key: 1, 2, 3

@NaturalId
@Column(nullable = false)
private String licensePlate;      // natural key: unique (licensePlate)
Vehicle civic = em.find(Vehicle.class, "ABC-123", KeyType.NATURAL);  // select ... where v1_0.licensePlate=?
Vehicle again = em.find(Vehicle.class, "ABC-123", KeyType.NATURAL);  // no SQL, again == civic
Vehicle none  = em.find(Vehicle.class, "NOP-000", KeyType.NATURAL);  // none = null

civic.setLicensePlate("ABC-999");   // error at commit: immutable natural identifier was altered

Notice that the second lookup by the same plate runs no SQL, and that Hibernate refuses to change an immutable plate.

Next, we compare surrogate and natural keys and build a parking app with three kinds of natural id. After that, we cache natural id lookups across EntityManager instances.

1. Surrogate Keys vs Natural Keys

Every table needs a key that identifies one row, and the key is either a surrogate key or a natural key.

  • A surrogate key is a value the database or Hibernate generates, such as id 1, 2, 3. It has no meaning outside the application.
  • A natural key is data that already identifies the thing in the real world, such as the plate “ABC-123” painted on a car.

We could use the plate as the primary key. The problem shows up in other tables, because every foreign key copies the plate, and a plate change must update all of them. A generated id as the primary key plus the plate as a natural id gives us stable foreign keys and lookups by real data.

Two designs side by side: on the left, the Vehicle table uses licensePlate as the primary key and the Ticket table stores vehicle_plate as a foreign key with the value ABC-123 in two rows; on the right, Vehicle has id as the primary key and a unique licensePlate column, and Ticket stores vehicle_id 1 as the foreign key
With a surrogate id, foreign keys never change. The plate stays unique and searchable through @NaturalId.

The @Id column and the @NaturalId column have different jobs.

Surrogate key (@Id)Natural id (@NaturalId)
Example1“ABC-123”
Who sets itHibernate (@GeneratedValue)Our code, from real data
Used by foreign keysYesNo
Database constraintPrimary keyUnique constraint
Can changeNeverNot by default; mutable = true allows it
Load by item.find(Vehicle.class, 1L)em.find(Vehicle.class, “ABC-123”, KeyType.NATURAL)

The annotation @NaturalId belongs to Hibernate (org.hibernate.annotations), not to Jakarta Persistence. Its advantage over a plain unique column and a JPQL query (the object query language of Jakarta Persistence) shows up when we load the same vehicle more than once, as FAQ 3.2 shows.

2. Natural Id Example

The following example is a small parking app with Hibernate 7.4, Java 25 and an in-memory H2 database. It has three entities, each with a different kind of natural id. The complete project on GitHub runs every case of section 2 and checks it with 17 JUnit tests.

2.1. Domain Model

The Vehicle entity has an immutable natural id, the plate. The Driver entity has a mutable one, because a driver gets a new license number after moving to another state. A ParkingSpot is identified by two fields together, namely the garage and the spot number.

@Id
@GeneratedValue
private Long id;

@NaturalId
@Column(nullable = false)
private String licensePlate;

private String model;
@NaturalId(mutable = true)
private String licenseNumber;

private String name;
@Entity
@NaturalIdClass(SpotKey.class)
public class ParkingSpot {

  @NaturalId
  private String garage;

  @NaturalId
  private int number;

  private boolean covered;
}

public record SpotKey(String garage, int number) {}

When Hibernate creates the schema, it adds a unique constraint for every natural id. For ParkingSpot, the constraint covers both columns.

create table Vehicle (id bigint not null, licensePlate varchar(255) not null, model varchar(255), primary key (id), unique (licensePlate))
create table Driver (id bigint not null, licenseNumber varchar(255), name varchar(255), primary key (id), unique (licenseNumber))
create table ParkingSpot (covered boolean not null, number integer not null, id bigint not null, garage varchar(255), primary key (id), unique (garage, number))

The column licenseNumber is nullable, because @NaturalId does not add not null. We add @Column(nullable = false) ourselves, as on Vehicle.

2.2. Loading a Vehicle by Its License Plate

Since Hibernate 7.3, we load by natural id with the standard em.find() method and the KeyType.NATURAL option. The enum KeyType is a Hibernate FindOption, the Jakarta Persistence 3.2 type for extra arguments of find().

emf.runInTransaction(em -> {
  Vehicle civic = em.find(Vehicle.class, "ABC-123", KeyType.NATURAL);  // select v1_0.id,v1_0.licensePlate,v1_0.model
                                                                       // from Vehicle v1_0 where v1_0.licensePlate=?
  Vehicle again = em.find(Vehicle.class, "ABC-123", KeyType.NATURAL);  // no SQL, again == civic
  Vehicle byId  = em.find(Vehicle.class, civic.getId());               // no SQL, byId == civic
});

Hibernate runs one select with a where on the plate and keeps the pair “ABC-123” -> id in the persistence context, the per-EntityManager store of loaded entities. The next lookup by plate or by id finds the same object there.

LookupSQL statements
First find() by plate1
Same plate again, same EntityManager0
find() by id afterwards0
Same plate in a new EntityManager, no second-level cache1
Same plate in a new EntityManager, with @NaturalIdCache and @Cache0
Unknown plate “NOP-000”1, returns null

A new EntityManager starts with an empty persistence context, so only the second-level cache, which all EntityManager instances share, avoids that SQL (section 2.6). Hibernate looks for the vehicle in the persistence context first, then in the caches, and only then in the database.

Flow of em.find(Vehicle.class, "ABC-123", KeyType.NATURAL): first the persistence context, a hit returns the same instance with 0 SQL; on a miss the natural id cache; a hit there goes to the entity cache, which returns the vehicle with 0 SQL or runs select where id; a miss in the natural id cache runs select where licensePlate, 1 SQL statement
Hibernate checks the persistence context first, then the second-level caches, and only then the database.

To load several vehicles in one query, we use findMultiple() from the Hibernate Session.

List<Vehicle> vehicles = em.unwrap(Session.class)
    .findMultiple(Vehicle.class, List.of("ABC-123", "JKL-456"), KeyType.NATURAL);
// select ... from Vehicle v1_0 where v1_0.licensePlate in (?,?)
// [Vehicle(ABC-123, Civic), Vehicle(JKL-456, Model 3)]

2.3. Changing an Immutable Natural Id

A natural id is immutable by default. Hibernate compares the natural id with its loaded value at flush time and rolls back the transaction when it changed.

emf.runInTransaction(em -> {
  Vehicle civic = em.find(Vehicle.class, "ABC-123", KeyType.NATURAL);
  civic.setLicensePlate("ABC-999");
});                                       // no update, RollbackException at commit
jakarta.persistence.RollbackException: Error while committing the transaction [An immutable natural identifier of entity com.howtodoinjava.hibernate.naturalid.Vehicle was altered from `ABC-123` to `ABC-999`]
Caused by: org.hibernate.HibernateException: An immutable natural identifier of entity com.howtodoinjava.hibernate.naturalid.Vehicle was altered from `ABC-123` to `ABC-999`

The row keeps “ABC-123”. If the value can change in our domain, the mapping must say so with mutable = true.

2.4. Changing a Mutable Natural Id

With @NaturalId(mutable = true), Hibernate writes the new value with a normal update. For example, Lokesh moves to another state and gets license number “L-200”.

emf.runInTransaction(em -> {
  Driver lokesh = em.find(Driver.class, "L-100", KeyType.NATURAL);
  lokesh.setLicenseNumber("L-200");
  Driver found = em.find(Driver.class, "L-200", KeyType.NATURAL);    // found == lokesh
});                                                                  // update Driver set licenseNumber=?,name=? where id=?

Driver previous = em.find(Driver.class, "L-100", KeyType.NATURAL);   // null, in a new EntityManager

The second find() runs before the update reaches the database, yet it finds Lokesh. Before a natural id lookup, Hibernate checks the loaded entities with a mutable natural id for changed values. The check has a performance cost, so we can turn it off with NaturalIdSynchronization.DISABLED when we know nothing changed.

lokesh.setLicenseNumber("L-200");
Driver notSeen = em.find(Driver.class, "L-200", KeyType.NATURAL, NaturalIdSynchronization.DISABLED);  // null, the change is not seen

2.5. Composite Natural Id

A spot number alone is not unique, because “Downtown” and “Airport” both have spot 14. When several fields carry @NaturalId, only their combination must be unique, and a lookup needs all of them.

The ParkingSpot table with rows Downtown 14, Downtown 15 and Airport 14 and a rejected fourth row Downtown 14; on the right, three ways to look up a spot: em.find with new SpotKey("Downtown", 14), em.find with a Map of garage and number, and session.byNaturalId with two using() calls; all three run one select where garage=? and number=?
The pair (garage, number) is unique. Any of the three lookup forms runs the same select.

The method find() accepts the natural id in three forms, and the first two are a @NaturalIdClass instance and a Map of attribute names to values.

ParkingSpot downtown = em.find(ParkingSpot.class, new SpotKey("Downtown", 14), KeyType.NATURAL);                // @NaturalIdClass instance
ParkingSpot airport = em.find(ParkingSpot.class, Map.of("garage", "Airport", "number", 14), KeyType.NATURAL);   // attribute name -> value
// select ps1_0.id,ps1_0.covered,ps1_0.garage,ps1_0.number from ParkingSpot ps1_0
// where ps1_0.garage=? and ps1_0.number=?

The third form is an array of the values, ordered alphabetically by attribute name, such as new Object[] {“Airport”, 14} for garage and number. The annotation @NaturalIdClass is new in Hibernate 7.3 and works like JPA’s @IdClass, so it names a plain class or record whose fields match the natural id attributes. A single value is not enough.

org.hibernate.metamodel.UnsupportedMappingException: Could not normalize compound natural id value: Downtown

2.6. Caching Natural Id Lookups With @NaturalIdCache

The annotation @NaturalIdCache stores the plate -> id pairs in the second-level cache, so a lookup in a new EntityManager skips the select on the plate. The cache needs a provider, and we use Ehcache through JCache.

<dependency>
  <groupId>org.hibernate.orm</groupId>
  <artifactId>hibernate-jcache</artifactId>
  <version>7.4.11.Final</version>
</dependency>
<dependency>
  <groupId>org.ehcache</groupId>
  <artifactId>ehcache</artifactId>
  <version>3.12.0</version>
  <classifier>jakarta</classifier>
  <exclusions>   <!-- its old JAXB dependencies point to repositories Maven blocks -->
    <exclusion>
      <groupId>*</groupId>
      <artifactId>*</artifactId>
    </exclusion>
  </exclusions>
</dependency>
new HibernatePersistenceConfiguration("natural-id")
    .property("hibernate.cache.use_second_level_cache", true)
    .property("hibernate.cache.region.factory_class", "jcache")
    .property("hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider")
    .property("hibernate.javax.cache.missing_cache_strategy", "create")   // no ehcache.xml needed
    // managedClasses, jdbcUrl, ...

The annotation @NaturalIdCache caches only the plate -> id pair. To also skip the select for the entity, the entity needs @Cache.

@Entity
@NaturalIdCache
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Vehicle { ... }

When we look up a vehicle (@NaturalIdCache and @Cache) and a parking spot (@NaturalIdCache only) in a new EntityManager, only the vehicle comes back without SQL.

EntityNatural id cache hitsEntity cache hitsSQL statements
Vehicle110
ParkingSpot101: select … from ParkingSpot ps1_0 where ps1_0.id=?
Vehicle, cache turned off001: select … where v1_0.licensePlate=?

We cache only immutable natural ids, because caching natural ids that change often has a negative impact on performance.

3. Natural Id FAQs

3.1. What Replaced bySimpleNaturalId() and byNaturalId() in Hibernate 7?

The call em.find() with KeyType.NATURAL replaced both. The old Session methods still work in Hibernate 7.4 but are deprecated since 7.3, and they run the same SQL.

Legacy API (deprecated since 7.3)Hibernate 7.3 and newer
session.bySimpleNaturalId(Vehicle.class).load(“XYZ-789”)em.find(Vehicle.class, “XYZ-789”, KeyType.NATURAL)
session.byNaturalId(ParkingSpot.class).using(“garage”, “Downtown”).using(“number”, 15).load()em.find(ParkingSpot.class, new SpotKey(“Downtown”, 15), KeyType.NATURAL)
session.byMultipleNaturalId(Vehicle.class).multiLoad(…)session.findMultiple(Vehicle.class, plates, KeyType.NATURAL)
…setSynchronizationEnabled(false)NaturalIdSynchronization.DISABLED

The legacy bySimpleNaturalId() also rejects a composite natural id.

org.hibernate.HibernateException: Cannot interpret natural id value [Downtown] as compound natural id of entity 'com.howtodoinjava.hibernate.naturalid.ParkingSpot'

The legacy getReference() returns a proxy, but it still needs the id, so it runs select v1_0.id from Vehicle v1_0 where v1_0.licensePlate=?.

3.2. Is @NaturalId Better Than a Unique Column and a Query?

For lookups, yes. Both run the same select the first time, but a JPQL query always goes to the database.

Vehicle byQuery = em.createQuery("from Vehicle where licensePlate = :plate", Vehicle.class)
    .setParameter("plate", "XYZ-789").getSingleResult();                    // 2 lookups = 2 statements

Vehicle byNaturalId = em.find(Vehicle.class, "XYZ-789", KeyType.NATURAL);   // 2 lookups = 1 statement

The query also has no immutability check and no @NaturalIdCache. We keep the query when the code must stay portable to another Jakarta Persistence provider.

3.3. What Happens When Two Vehicles Get the Same Plate?

The unique constraint rejects the second row at commit, and Hibernate wraps the error in a ConstraintViolationException.

jakarta.persistence.RollbackException: Error while committing the transaction [could not execute statement [Unique index or primary key violation: "PUBLIC.CONSTRAINT_3F INDEX PUBLIC.CONSTRAINT_INDEX_3 ON PUBLIC.VEHICLE(LICENSEPLATE NULLS FIRST) VALUES ( /* 1 */ 'ABC-123' )"; ...]]

Hibernate does not check for duplicates before the insert. To show a friendly message, we either look the plate up first or catch the exception.

3.4. Can a Natural Id Be Null?

In theory no, because a natural id may never be null, but Hibernate does not enforce the rule. The licenseNumber column of Driver is nullable, and a driver with a null license number is saved. With @Column(nullable = false) on Vehicle, Hibernate stops at persist().

org.hibernate.PropertyValueException: not-null property references a null or transient value for entity com.howtodoinjava.hibernate.naturalid.Vehicle.licensePlate

Two other options also block a null plate, namely @NotNull from Bean Validation and @Basic(optional = false).

3.5. Should equals() and hashCode() Use the Natural Id?

Yes, when the natural id is immutable and set in the constructor. Unlike the generated id, the plate has its value before em.persist(), so a vehicle stays findable in a HashSet before and after saving.

@Override
public boolean equals(Object o) {
  return o instanceof Vehicle other && licensePlate != null && licensePlate.equals(other.licensePlate);
}

@Override
public int hashCode() {
  return Objects.hashCode(licensePlate);
}
Vehicle prius = new Vehicle("PQR-321", "Prius");
Set<Vehicle> fleet = new HashSet<>(Set.of(prius));
emf.runInTransaction(em -> em.persist(prius));   // prius gets its id
boolean saved = fleet.contains(prius);           // true

Vehicle loaded = emf.callInTransaction(em -> em.find(Vehicle.class, "PQR-321", KeyType.NATURAL));
boolean found = fleet.contains(loaded);          // true, a different instance

Other ways to define entity equality between sessions have their own trade-offs. For a mutable natural id, the hash code would change, so we do not use it there.

4. Conclusion

We keep a generated id as the primary key and mark real-world identifiers such as a license plate with @NaturalId. Hibernate adds the unique constraint and loads the entity with em.find(…, KeyType.NATURAL), returning it from the persistence context on repeated lookups. It blocks changes unless the mapping says mutable = true. For lookups across EntityManager instances, @NaturalIdCache together with @Cache removes the SQL completely.

5. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.