A Hibernate interceptor is a class that implements org.hibernate.Interceptor and gets a callback from the Session whenever an entity is persisted, changed, removed or loaded. Each callback gets the entity, its id, the property names and the property values, and for an update both the old and the new values.
We use an interceptor for audit trails and default values that must work for every entity without touching the entity classes. For example, a clinic app records who moved an appointment to another doctor, and one interceptor class does it for all its entities.
The following example logs every changed property of an Appointment in onFlushDirty() and opens a session with that interceptor.
public class AuditInterceptor implements Interceptor {
@Override
public boolean onFlushDirty(Object entity, Object id, Object[] currentState,
Object[] previousState, String[] propertyNames, Type[] types) {
for (int i = 0; i < propertyNames.length; i++) {
if (!Objects.equals(previousState[i], currentState[i])) {
pending.add(new AuditLog("Appointment", (Long) id, "update", propertyNames[i],
String.valueOf(previousState[i]), String.valueOf(currentState[i])));
}
}
return false; // false: currentState was not changed
}
}
Session session = AuditInterceptor.openSession(sessionFactory); // withOptions().interceptor(audit)
session.beginTransaction();
Appointment appointment = session.find(Appointment.class, id);
appointment.setDoctor("Dr. Rao");
session.getTransaction().commit(); // update Appointment set doctor=?,... where id=?
// audit: doctor Dr. Mehta -> Dr. Rao
Notice that the callback gets both the loaded and the new value of each property, so the audit row holds the old doctor and the new one without any change to the Appointment class.
Next, we see when Hibernate calls each callback and how an interceptor compares with event listeners and JPA callbacks. After that, we build the complete audit trail step by step, including rollback, the interceptor scope, the StatementInspector and the Spring Boot setup.
1. How Does a Hibernate Interceptor Work?
A Session keeps every loaded or persisted entity in its persistence context. Nothing reaches the database until the session flushes, which compares each entity with the copy taken at load time and sends the SQL for the differences. Hibernate calls the interceptor at fixed points of the flush, so the interceptor sees each change before the SQL is built.

The Interceptor interface in Hibernate 7.4 has only default methods, so we override only the callbacks we need. Each callback of a regular (stateful) Session runs at its own point, and only some of them can change the row.
| Callback | When Hibernate calls it | Can it change the row? |
|---|---|---|
| onPersist() | At persist(), before any SQL | Yes, change state and return true |
| onLoad() | After the SELECT, before the entity gets its values | Yes, change state and return true |
| preFlush() | At the start of every flush | No |
| findDirty() | During the flush, for every entity | Returns the dirty property indexes, or null for the default check |
| onFlushDirty() | During the flush, only for changed entities | Yes, change currentState and return true |
| onRemove() | At remove(), before the DELETE | No |
| postFlush() | After the flush has sent its SQL | No |
| onCollectionUpdate(), onCollectionRecreate(), onCollectionRemove() | Before a collection’s SQL | No |
| afterTransactionBegin(), beforeTransactionCompletion(), afterTransactionCompletion() | Around the transaction | No |
The state arrays, propertyNames and types share the same index, and Hibernate orders the properties alphabetically, so for Appointment they are doctor, patientName, slot, status. A StatelessSession uses its own callbacks, namely onInsert(), onUpdate(), onUpsert() and onDelete(entity, id, propertyNames, types).
Three APIs from older tutorials changed in Hibernate 6.6 and 7.
- The class EmptyInterceptor is no longer in the public API. In Hibernate 7 it moved to org.hibernate.internal and is final, so we implement Interceptor directly.
- The callback onSave() is deprecated since 6.6. It belonged to the removed Session.save(), and onPersist() replaces it.
- The five-argument onDelete() is deprecated since 6.6, and onRemove() replaces it.
Hibernate offers two other ways to react to entity changes. JPA lifecycle callbacks such as @PreUpdate are part of the specification, and Hibernate event listeners plug into Hibernate’s own event processing. The interceptor shares features with both of them.
| Feature | Interceptor | Event listener | JPA callbacks (@PreUpdate …) |
|---|---|---|---|
| Part of | Hibernate | Hibernate | Jakarta Persistence (portable) |
| Old and new values | Yes, in onFlushDirty() | Yes (PostUpdateEvent) | No, only the current entity |
| One class for all entities | Yes | Yes | Needs @EntityListeners or a default listener |
| Collection changes | Yes | Yes | No |
| Scope | One session or the whole SessionFactory | The whole SessionFactory | Every use of the entity |
| Registration | withOptions().interceptor() or a setting | Integrator and EventListenerRegistry | Annotations |
We pick an interceptor when we need old and new values for many entities, or a different behavior for some sessions only. For one entity that only needs to fill a field, a JPA callback is simpler.
2. Building an Audit Trail With an Interceptor
The following example is a clinic app that books doctor appointments with Hibernate 7.4, Java 25 and an in-memory H2 database. We write every change of an appointment, from the booking to the cancellation, to an audit table, in the same transaction as the change.
2.1. Appointment and AuditLog Entities
The Appointment class is the business entity, and AuditLog holds one row per changed property, with the old and the new value.
@Id
@GeneratedValue
private Long id;
private String patientName;
private String doctor;
private LocalDateTime slot;
private String status;
@Id
@GeneratedValue
private Long id;
private String entityName; // "Appointment"
private Long entityId; // 1
private String action; // insert, update or delete
private String property; // "doctor"
private String oldValue; // "Dr. Mehta"
private String newValue; // "Dr. Rao"
Neither class knows about auditing, because both are plain @Entity classes, registered with HibernatePersistenceConfiguration.
2.2. Recording a New Appointment With onPersist
The callback onPersist() runs inside persist(), before Hibernate builds the INSERT. We use it for two jobs, to fill a default status and to queue an audit row.
@Override
public boolean onPersist(Object entity, Object id, Object[] state,
String[] propertyNames, Type[] types) {
int status = indexOf(propertyNames, "status");
boolean changed = false;
if (state[status] == null) {
state[status] = "booked"; // change the state array, not the entity
changed = true;
}
pending.add(new AuditLog("Appointment", (Long) id, "insert", null, null, null));
return changed; // true: Hibernate copies state back to the entity
}
A callback changes data through the state array and returns true. Hibernate then uses the array for the INSERT and copies it back to the object.
Appointment lokesh = new Appointment("Lokesh", "Dr. Mehta", NINE);
session.persist(lokesh); // select next value for Appointment_SEQ
String status = lokesh.getStatus(); // "booked", no INSERT sent yet
session.getTransaction().commit(); // insert into Appointment (doctor,patientName,slot,status,id) values (?,?,?,?,?)
// insert into AuditLog (action,entityId,...) values (?,?,?,?,?,?,?)
The id is already known in onPersist(), because Hibernate read the next value of the sequence before calling it.
2.3. Recording Old and New Values With onFlushDirty
The callback onFlushDirty() runs during the flush, once for each entity whose values differ from the loaded copy. The previousState array holds the values from the database and currentState the values about to be written, so the changed properties are the indexes where the two differ.
@Override
public boolean onFlushDirty(Object entity, Object id, Object[] currentState,
Object[] previousState, String[] propertyNames, Type[] types) {
for (int i = 0; i < propertyNames.length; i++) {
if (!Objects.equals(previousState[i], currentState[i])) {
pending.add(new AuditLog("Appointment", (Long) id, "update", propertyNames[i],
String.valueOf(previousState[i]), String.valueOf(currentState[i])));
}
}
return false;
}
We move Lokesh’s appointment from 9:00 to 11:00 and give it to another doctor. Hibernate sends one UPDATE for the appointment, and the interceptor adds one audit row per property.
Appointment a = session.find(Appointment.class, id); // select ... from Appointment a1_0 where a1_0.id=?
a.setSlot(ELEVEN);
a.setDoctor("Dr. Rao");
session.getTransaction().commit(); // update Appointment set doctor=?,patientName=?,slot=?,status=? where id=?
// insert into AuditLog ... (2 rows)
insert Appointment#1
update Appointment#1 doctor: Dr. Mehta -> Dr. Rao
update Appointment#1 slot: 2026-11-12T09:00 -> 2026-11-12T11:00
Because patientName and status did not change, the interceptor writes no row for them.
2.4. Recording a Deleted Appointment With onRemove
The callback onRemove() runs inside remove() and gets the last state of the entity. It cannot change anything, so it returns void.
@Override
public void onRemove(Object entity, Object id, Object[] state,
String[] propertyNames, Type[] types) {
pending.add(new AuditLog("Appointment", (Long) id, "delete", null, null, null));
}
session.remove(session.find(Appointment.class, id));
session.getTransaction().commit(); // delete from Appointment where id=?
// insert into AuditLog ... (delete Appointment#1)
2.5. Writing the Audit Rows in the Same Transaction
The callbacks only collect rows in the pending list, because a callback must not use the Session (no persist(), no query, no lazy loading). We write the rows in postFlush(), after the flush has sent its SQL, through a StatelessSession that shares the session’s JDBC connection.
@Override
public void postFlush(Iterator<Object> entities) {
if (pending.isEmpty()) {
return;
}
try (StatelessSession writer = session.statelessWithOptions()
.connection() // same JDBC connection and transaction
.noInterceptor() // the audit rows are not audited again
.open()) {
pending.forEach(writer::insert);
}
pending.clear();
}
The interceptor needs that session field, so a small factory method opens the session and sets the field.
public static Session openSession(SessionFactory sessionFactory) {
AuditInterceptor audit = new AuditInterceptor();
Session session = sessionFactory.withOptions().interceptor(audit).openSession();
audit.session = session;
return session;
}
Because the audit rows use the same connection, they commit or roll back together with the change. For example, when a receptionist cancels an appointment and the refund step fails, the rollback removes the audit row together with the status change. A cancellation that is flushed and then rolled back leaves no audit row.
session.find(Appointment.class, id).setStatus("cancelled");
session.flush(); // update Appointment ... + insert into AuditLog ...
session.getTransaction().rollback(); // both are undone

2.6. Session-Scoped and SessionFactory-Scoped Interceptors
We register an interceptor in one of two scopes. A session-scoped interceptor is a new object for each Session, whereas a SessionFactory-scoped interceptor is one object shared by every session of the SessionFactory. In a web app, each request runs its session in its own thread, so the shared object is called by many threads at the same time.

An interceptor with fields that belong to one unit of work, like our pending list, must be session-scoped. A shared interceptor keeps only thread-safe data, such as the LoggingInterceptor that logs every change in all sessions.
private final List<String> log = new CopyOnWriteArrayList<>();
@Override
public boolean onFlushDirty(Object entity, Object id, Object[] currentState,
Object[] previousState, String[] propertyNames, Type[] types) {
log.add(entity.getClass().getSimpleName() + "#" + id + " changed from "
+ Arrays.toString(previousState) + " to " + Arrays.toString(currentState));
return false;
}
SessionFactory sf = new HibernatePersistenceConfiguration("clinic")
.managedClasses(Appointment.class, AuditLog.class)
.property(SessionEventSettings.INTERCEPTOR, new LoggingInterceptor())
// ... JDBC settings
.createEntityManagerFactory()
.unwrap(SessionFactory.class);
sf.inTransaction(s -> s.find(Appointment.class, id).setStatus("confirmed"));
// Appointment#1 changed from [Dr. Mehta, Anna, 2026-11-12T09:00, null] to [Dr. Mehta, Anna, 2026-11-12T09:00, confirmed]
The ways to register an interceptor differ in how many instances Hibernate uses.
| Registration | Instances | Typical use |
|---|---|---|
| sessionFactory.withOptions().interceptor(new AuditInterceptor()) | One per session we open | Interceptors with state, or only some sessions |
| Setting hibernate.session_factory.session_scoped_interceptor (a class or a Supplier) | One per session, created by Hibernate | Stateful interceptors in every session; needs a public no-argument constructor |
| Setting hibernate.session_factory.interceptor (an instance, class or class name) | One for all sessions | Stateless logging or checks |
An interceptor passed to withOptions() replaces the factory-wide one for that session, so the factory-wide interceptor does not run in addition to it.
2.7. Logging and Rewriting SQL With StatementInspector
An Interceptor sees entities, whereas a StatementInspector sees the SQL text. Hibernate passes every statement to its single method inspect(String sql) and executes the string the method returns. The SqlTagInspector adds a comment to each statement, so a DBA who checks slow queries in the database logs can see which ones came from the clinic app.
public class SqlTagInspector implements StatementInspector {
private final List<String> statements = new CopyOnWriteArrayList<>();
@Override
public String inspect(String sql) {
String tagged = "/* clinic-app */ " + sql;
statements.add(tagged);
return tagged; // Hibernate executes this string
}
}
new HibernatePersistenceConfiguration("clinic")
.statementInspector(new SqlTagInspector())
// ...
sf.inTransaction(s -> s.persist(new Appointment("Lokesh", "Dr. Mehta", NINE)));
sf.inTransaction(s -> s.createSelectionQuery("from Appointment where doctor = :d", Appointment.class)
.setParameter("d", "Dr. Mehta").getResultList());
/* clinic-app */ select next value for Appointment_SEQ
/* clinic-app */ insert into Appointment (doctor,patientName,slot,status,id) values (?,?,?,?,?)
/* clinic-app */ select a1_0.id,a1_0.doctor,a1_0.patientName,a1_0.slot,a1_0.status from Appointment a1_0 where a1_0.doctor=?
The schema statements (create table) did not pass the inspector, only the statements of our sessions did. The inspector is shared by all sessions, so its list is a thread-safe CopyOnWriteArrayList. For one session only, we pass a lambda to the session builder, and the lambda needs a declared type because statementInspector() is overloaded for StatementInspector and UnaryOperator<String>.
UnaryOperator<String> collect = sql -> {
seen.add(sql);
return sql;
};
Session session = sf.withOptions().statementInspector(collect).openSession();
A StatementInspector only gets the SQL string with ? placeholders, never the parameter values.
2.8. Registering an Interceptor in Spring Boot
Spring Boot passes every property that starts with spring.jpa.properties. to Hibernate. A shared interceptor and an inspector need only their class names.
spring.jpa.properties.hibernate.session_factory.interceptor=com.howtodoinjava.hibernate.interceptors.LoggingInterceptor
spring.jpa.properties.hibernate.session_factory.statement_inspector=com.howtodoinjava.hibernate.interceptors.SqlTagInspector
When the interceptor is a Spring bean with its own dependencies, a HibernatePropertiesCustomizer bean (package org.springframework.boot.hibernate.autoconfigure in Spring Boot 4) puts the instance into the Hibernate settings.
@Bean
HibernatePropertiesCustomizer interceptorCustomizer(LoggingInterceptor loggingInterceptor) {
return properties -> properties.put("hibernate.session_factory.interceptor", loggingInterceptor);
}
Spring opens the sessions itself, so an interceptor that needs its own Session, like our AuditInterceptor, does not fit the class-name settings, because it has no way to receive the session.
The complete project on GitHub runs every step of this section and prints the SQL with the order of the callbacks, and its 17 JUnit tests check the results (mvn -q compile exec:java, mvn test).
3. Hibernate Interceptor FAQs
3.1. Is EmptyInterceptor Deprecated in Hibernate 7?
No, it is gone from the public API. The class org.hibernate.EmptyInterceptor was deprecated in Hibernate 6.0, and in Hibernate 7 only an internal, final org.hibernate.internal.EmptyInterceptor is left. Since every Interceptor method is a default method, we implement the interface itself.
// Hibernate 5: extend the adapter class
public class AuditInterceptor extends EmptyInterceptor { ... }
// Hibernate 6 and 7: implement the interface, override what we need
public class AuditInterceptor implements Interceptor { ... }
3.2. Why Are My Changes in onFlushDirty Not Saved?
The changes are lost because Hibernate builds the UPDATE from the currentState array, not from the entity object. Setting a field on entity inside the callback is too late for this flush, so we change the array instead.
| Code inside onFlushDirty() | Doctor in the database |
|---|---|
| ((Appointment) entity).setDoctor(“Dr. Rao”); return false; | Dr. Mehta (change lost) |
| currentState[0] = “Dr. Rao”; return true; | Dr. Rao, and the entity is updated too |
Index 0 is doctor, the first property in alphabetical order. In real code we look the index up in propertyNames instead of hard-coding it.
3.3. Why Is onFlushDirty Not Called?
The entity is not a candidate for it. The callback onFlushDirty() runs only for managed entities whose values changed, so it does not run in four common cases.
- The entity has no changes. Hibernate still calls findDirty() for it, but not onFlushDirty().
- The entity is new. A new entity goes through onPersist(), never onFlushDirty().
- The change is a bulk JPQL update. It changes rows without loading entities, so no entity callback runs.
int updated = session.createMutationQuery("update Appointment set status = 'confirmed'")
.executeUpdate(); // 2 rows updated, no onFlushDirty, no AuditLog rows
- The interceptor is not registered on that session. A session opened with withOptions().interceptor(other) uses other instead of the factory-wide interceptor.
3.4. In Which Order Does Hibernate Call the Interceptor Methods?
An interceptor that prints each call shows the order. For a new appointment in one transaction and a status change in a second one, the calls come in this order.
persist() + commit(): find() + setStatus() + commit():
afterTransactionBegin afterTransactionBegin
onPersist onLoad
preFlush preFlush
findDirty findDirty
(insert into Appointment ...) onFlushDirty
postFlush (update Appointment ...)
beforeTransactionCompletion postFlush
afterTransactionCompletion beforeTransactionCompletion
afterTransactionCompletion
The flush runs before beforeTransactionCompletion(), so all SQL, including our audit inserts, is sent before the commit.
3.5. Should I Use an Interceptor or Hibernate Envers for Auditing?
We use an interceptor when we want a simple change log in our own table format. Hibernate Envers is a separate Hibernate module that stores a full copy of each entity version in _AUD tables with revision numbers and can query the entity as it was at any revision. For “show this record as it was last Tuesday”, we use Envers; for “who changed which field”, an interceptor is enough.
4. Conclusion
A Hibernate interceptor implements org.hibernate.Interceptor and overrides only the callbacks it needs. For an audit trail, onFlushDirty() gives the old and new values of changed properties, whereas onPersist() and onRemove() cover new and deleted entities.
An interceptor changes data only through the state arrays and never calls the Session from a callback. It must be session-scoped whenever it keeps data for one unit of work. For the SQL text itself, a StatementInspector is the right hook.
5. References
- Hibernate ORM 7.4 User Guide: Interceptors
- Hibernate ORM 7.4 User Guide: hibernate.session_factory.interceptor
- Interceptor JavaDoc (Hibernate 7.4)
- StatementInspector JavaDoc (Hibernate 7.4)
- PreUpdate JavaDoc (Jakarta Persistence 3.2)
- HibernatePropertiesCustomizer JavaDoc (Spring Boot)
Happy Learning !!