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.

The @Id column and the @NaturalId column have different jobs.
| Surrogate key (@Id) | Natural id (@NaturalId) | |
|---|---|---|
| Example | 1 | “ABC-123” |
| Who sets it | Hibernate (@GeneratedValue) | Our code, from real data |
| Used by foreign keys | Yes | No |
| Database constraint | Primary key | Unique constraint |
| Can change | Never | Not by default; mutable = true allows it |
| Load by it | em.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.
| Lookup | SQL statements |
|---|---|
| First find() by plate | 1 |
| Same plate again, same EntityManager | 0 |
| find() by id afterwards | 0 |
| Same plate in a new EntityManager, no second-level cache | 1 |
| Same plate in a new EntityManager, with @NaturalIdCache and @Cache | 0 |
| 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.

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 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.
| Entity | Natural id cache hits | Entity cache hits | SQL statements |
|---|---|---|---|
| Vehicle | 1 | 1 | 0 |
| ParkingSpot | 1 | 0 | 1: select … from ParkingSpot ps1_0 where ps1_0.id=? |
| Vehicle, cache turned off | 0 | 0 | 1: 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
- Hibernate ORM 7.4 User Guide: Natural Ids
- NaturalId JavaDoc (Hibernate 7.4)
- NaturalIdCache JavaDoc (Hibernate 7.4)
- NaturalIdClass JavaDoc (Hibernate 7.4)
- KeyType JavaDoc (Hibernate 7.4)
- FindOption JavaDoc (Jakarta Persistence 3.2)
Happy Learning !!