JPA lifecycle callbacks are methods that Hibernate calls by itself when an entity is persisted, updated, removed or loaded. We mark a method with one of seven annotations, such as @PrePersist or @PreUpdate, and Hibernate runs it at that moment of the entity’s life.
We use callbacks for logic that must run on every save of an entity, such as setting timestamps and default values, or validating data before it reaches the database. For example, an expense claim app stamps every new claim with its submission time, so no service method has to remember it.
The following example sets default values in @PrePersist and a modification time in @PreUpdate, and lets Hibernate call both methods.
@PrePersist
void onSubmit() {
submittedAt = LocalDateTime.now();
if (status == null) {
status = ClaimStatus.SUBMITTED;
}
}
@PreUpdate
void onUpdate() {
updatedAt = LocalDateTime.now();
}
ExpenseClaim lunch = new ExpenseClaim("Team lunch", new BigDecimal("40.00"));
emf.runInTransaction(em -> {
em.persist(lunch); // @PrePersist, insert into ExpenseClaim ..., @PostPersist
ClaimStatus status = lunch.getStatus(); // SUBMITTED
});
emf.runInTransaction(em -> {
ExpenseClaim claim = em.find(ExpenseClaim.class, lunch.getId()); // select ..., @PostLoad
claim.setStatus(ClaimStatus.APPROVED);
}); // commit: @PreUpdate, update ExpenseClaim set ..., @PostUpdate
Notice that our code never calls onSubmit() or onUpdate(), and that the @PreUpdate method runs at commit, not at the setter call.
Next, we go through the seven callbacks and when each one runs, and then move the methods into listener classes. The last sections cover what a callback must not do and the Hibernate event listeners that go beyond the seven events.
1. What Are JPA Lifecycle Callbacks?
An entity moves through four entity states, namely new, managed, removed and detached. Each move between these states is a lifecycle event, and Jakarta Persistence lets us attach a method to it. Most events have a Pre callback that runs before the operation and a Post callback that runs after the SQL statement.

The seven annotations are in the jakarta.persistence package, and each one runs at its own point.
| Annotation | Runs | Typical use |
|---|---|---|
| @PrePersist | Inside em.persist(), before the INSERT | Default values, creation time |
| @PostPersist | After the INSERT | Read the generated id, send a notification |
| @PreUpdate | At flush, before the UPDATE of a changed entity | Modification time, validation |
| @PostUpdate | After the UPDATE | Notification |
| @PreRemove | Inside em.remove() | Block deletion, clean up references |
| @PostRemove | After the DELETE | Delete files that belonged to the row |
| @PostLoad | After find(), a query or refresh() loads the entity | Fill a @Transient field |
A callback method can sit in three places, which all use the same annotations but differ in scope.
| Where | Method signature | Applies to |
|---|---|---|
| On the entity class | void onSubmit(), no parameters | That entity only |
| In a listener class named in @EntityListeners | void validate(ExpenseClaim claim) | Each entity that lists the class |
| In a default listener declared in orm.xml | void beforeInsert(Object entity) | Every entity in the persistence unit |
The specification sets a few rules for callback methods.
- The method returns void and can be public, protected, private or package-private, but not static or final.
- One class may have only one method per event.
- One method may carry several annotations, such as @PrePersist and @PreUpdate together.
Hibernate also has its own event listener API, which goes beyond the seven events, as section 5 shows.
2. Entity Lifecycle Callbacks Example
The following example is an expense claim app with Hibernate 7.4, Java 25 and an in-memory H2 database. An employee submits a claim such as “Team lunch” for 40.00, and a manager approves it later. The complete project on GitHub prints every callback between the SQL statements, and its 19 JUnit tests check each result (mvn -q compile exec:java, mvn test).
2.1. The ExpenseClaim Entity
The ExpenseClaim entity uses @GeneratedValue with an IDENTITY id, so the database generates the key during the INSERT. The callback methods set submittedAt, the default status and updatedAt, so no service code has to remember them.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
private BigDecimal amount;
@Enumerated(EnumType.STRING)
private ClaimStatus status; // SUBMITTED, APPROVED, REJECTED
private LocalDateTime submittedAt; // set in @PrePersist
private LocalDateTime updatedAt; // set in @PreUpdate
@Transient
private boolean loaded; // set in @PostLoad
The other five callbacks print a line so we can see when they run.
@PostPersist
void afterSubmit() {
CallbackLog.add("ExpenseClaim @PostPersist id=" + id);
}
@PostLoad
void afterLoad() {
CallbackLog.add("ExpenseClaim @PostLoad");
loaded = true;
}
// @PostUpdate, @PreRemove and @PostRemove look the same
2.2. When Each Callback Runs
A callback runs when Hibernate executes the operation, not when our code changes a field. @PrePersist and @PreRemove run inside the persist() and remove() calls. A setter runs nothing; the UPDATE and the update callbacks wait for the flush, which is the moment Hibernate sends pending changes to the database (at commit, or when we call em.flush()).
emf.runInTransaction(em -> {
ExpenseClaim lunch = em.find(ExpenseClaim.class, id); // select ..., @PostLoad
lunch.setStatus(ClaimStatus.APPROVED); // nothing yet
}); // commit: @PreUpdate, update ExpenseClaim set ..., @PostUpdate
emf.runInTransaction(em -> {
ExpenseClaim lunch = em.find(ExpenseClaim.class, id); // select ..., @PostLoad
em.remove(lunch); // @PreRemove
}); // commit: delete from ExpenseClaim where id=?, @PostRemove
The value set in @PreUpdate goes into the same UPDATE, so after the approval commits, the row has updatedAt filled in. The timeline puts every operation side by side.

2.3. @PostPersist With IDENTITY and SEQUENCE Ids
The id generation strategy decides when @PostPersist runs. With IDENTITY, Hibernate must run the INSERT inside persist() to get the id from the database, so @PostPersist runs inside persist() too.
emf.runInTransaction(em -> {
em.persist(lunch); // @PrePersist (id = null), insert into ExpenseClaim ..., @PostPersist (id = 1)
Long newId = lunch.getId(); // 1, before the commit
});
TravelClaim, a second entity for trips such as a taxi ride, uses a SEQUENCE id. Hibernate reads the next sequence value first, so the id is already set in @PrePersist, and the INSERT with @PostPersist waits for the flush.
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
-- em.persist(taxi)
Hibernate: select next value for TravelClaim_SEQ
TravelClaim @PrePersist id=1 submittedAt=null
-- persist() returned, id = 1, submittedAt = null
-- em.flush()
Hibernate: insert into TravelClaim (amount,description,submittedAt,updatedAt,id) values (?,?,?,?,?)
TravelClaim @PostPersist id=1 submittedAt=set
-- flush() returned, submittedAt set = true
| Id strategy | Id in @PrePersist | INSERT and @PostPersist run |
|---|---|---|
| IDENTITY | null | Inside persist() |
| SEQUENCE | Already set | At flush (commit or em.flush()) |
Jakarta Persistence makes SEQUENCE, TABLE and UUID ids available in @PrePersist, but not IDENTITY ids, and the generated id is always available in @PostPersist.
3. Entity Listener Classes and Default Listeners
Callback methods on the entity are fine for logic that belongs to that entity. When the same logic serves several entities, or we want to keep the entity class small, we move the methods into a listener class.
3.1. A Listener Class With @EntityListeners
An entity listener is a plain class with a public no-argument constructor. Its callback methods take the entity as a parameter. ClaimValidationListener rejects a claim without a positive amount, on insert and on update.
public class ClaimValidationListener {
@PrePersist
@PreUpdate
void validate(ExpenseClaim claim) {
if (claim.getAmount() == null || claim.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Claim amount must be positive: " + claim.getAmount());
}
}
}
ClaimNotificationListener sends a notification after the INSERT and the UPDATE. In the example it only prints a line, where a real app sends an email to the manager.
@PostPersist
void submitted(ExpenseClaim claim) {
CallbackLog.add("ClaimNotificationListener notify manager: claim " + claim.getId() + " submitted");
}
@PostUpdate
void changed(ExpenseClaim claim) {
CallbackLog.add("ClaimNotificationListener notify employee: claim " + claim.getId() + " is " + claim.getStatus());
}
The entity names its listeners in @EntityListeners.
@Entity
@EntityListeners({ClaimValidationListener.class, ClaimNotificationListener.class})
public class ExpenseClaim {
A listener for several entity types declares the parameter as Object or as a common supertype. Hibernate creates the listener instance and expects a stateless class, so keep no per-entity data in listener fields.
3.2. Default Listeners in orm.xml
A default listener runs for every entity in the persistence unit, without any annotation on the entities. We use it for logic that every entity needs, for example an audit line for each insert. We declare it in the META-INF/orm.xml XML mapping file.
<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_2.xsd"
version="3.2">
<persistence-unit-metadata>
<persistence-unit-defaults>
<entity-listeners>
<entity-listener class="com.howtodoinjava.hibernate.callbacks.AuditListener"/>
</entity-listeners>
</persistence-unit-defaults>
</persistence-unit-metadata>
</entity-mappings>
@PrePersist
void beforeInsert(Object entity) {
CallbackLog.add("AuditListener @PrePersist " + entity.getClass().getSimpleName());
}
With HibernatePersistenceConfiguration, we add the file with mappingFile().
new HibernatePersistenceConfiguration("entity-callbacks-claims")
.managedClasses(ExpenseClaim.class, TravelClaim.class, ClaimHistory.class)
.mappingFile("META-INF/orm.xml")
// jdbcUrl(), jdbcCredentials(), ...
.createEntityManagerFactory();
An entity that must not get the default listeners uses @ExcludeDefaultListeners. TravelClaim has it, so persisting a travel claim prints no AuditListener line.
3.3. Order of Multiple Listeners
When several callbacks exist for the same event, the specification fixes the order. Default listeners run first, then the @EntityListeners classes in the order we list them, and then the methods on the entity itself.

The full log of the approval shows the same order for the update callbacks.
-- commit
AuditListener @PreUpdate ExpenseClaim
ClaimValidationListener validate
ExpenseClaim @PreUpdate
Hibernate: update ExpenseClaim set amount=?,description=?,status=?,submittedAt=?,updatedAt=? where id=?
ClaimNotificationListener notify employee: claim 1 is APPROVED
ExpenseClaim @PostUpdate
3.4. Rejecting an Entity From a Callback
A callback may throw a runtime exception. The exception stops the chain, so no later callback runs and the transaction rolls back. So @PrePersist is a good place for a last check, for example against a claim with a negative amount from a buggy client.
emf.runInTransaction(em ->
em.persist(new ExpenseClaim("Coffee", new BigDecimal("-5.00"))));
AuditListener @PrePersist ExpenseClaim
ClaimValidationListener validate
java.lang.IllegalArgumentException: Claim amount must be positive: -5.00
ExpenseClaim.onSubmit() never ran, and the table has no new row. For simple field rules such as “not null” or “positive”, Bean Validation annotations such as @Positive are the more common choice; a callback suits checks that combine several fields.
4. What Not to Do in a Callback
The specification allows a callback to change the simple fields of its own entity. A portable callback should not call EntityManager or query operations, and it should not access other entities or change relationships. Hibernate 7.4 reacts differently to each rule break.
| Inside the callback | What happened in Hibernate 7.4 |
|---|---|
| Run a JPQL query in @PreUpdate | StackOverflowError, transaction rolled back |
| Call em.persist() for another entity in @PostPersist | Works: the row is inserted at commit, but it is not portable |
| Change a field in @PostPersist (IDENTITY id) | A second UPDATE at commit, with @PreUpdate and @PostUpdate again |
| Change a field in @PostUpdate | The change is lost; it never reaches the database |
4.1. A Query in @PreUpdate
A listener that checks the total of all claims before each update looks harmless.
@PreUpdate
void checkBudget(Object entity) {
BigDecimal total = em.createQuery("select sum(c.amount) from ExpenseClaim c", BigDecimal.class)
.getSingleResult();
}
The callback runs during the flush. Before a query, Hibernate flushes pending changes so the query sees them (auto-flush). That flush finds the same changed claim and calls @PreUpdate again, which runs the query again.
jakarta.persistence.RollbackException: Error while committing the transaction [java.lang.StackOverflowError]
Caused by: java.lang.StackOverflowError
...
at org.hibernate.internal.SessionImpl.autoFlushIfRequired(SessionImpl.java:1423)
...
at org.hibernate.query.spi.AbstractSelectionQuery.getSingleResult(AbstractSelectionQuery.java:292)
at com.howtodoinjava.hibernate.callbacks.RiskyListener.checkBudget(RiskyListener.java:27)
...
at org.hibernate.jpa.event.internal.CallbackRegistryImpl.preUpdate(CallbackRegistryImpl.java:80)
at org.hibernate.event.internal.DefaultFlushEntityEventListener.invokeInterceptor(DefaultFlushEntityEventListener.java:341)
...
at org.hibernate.event.internal.DefaultAutoFlushEventListener.onAutoFlush(DefaultAutoFlushEventListener.java:47)
...
at org.hibernate.internal.SessionImpl.autoFlushIfRequired(SessionImpl.java:1423)
... (the same frames repeat until the stack is full)
The amount in the database stays 90.00. Run budget checks in the service method before changing the entity, where queries are safe.
4.2. em.persist() in @PostPersist
Writing a history row from a callback is a common idea, for example to record when a claim was submitted.
@PostPersist
void afterSubmit(Object entity) {
if (entity instanceof ExpenseClaim claim) {
em.persist(new ClaimHistory(claim.getId(), "submitted"));
}
}
Hibernate: insert into ExpenseClaim (amount,description,status,submittedAt,updatedAt,id) values (?,?,?,?,?,default)
Hibernate: select next value for ClaimHistory_SEQ
ClaimNotificationListener notify manager: claim 2 submitted
ExpenseClaim @PostPersist id=2
-- commit
Hibernate: insert into ClaimHistory (action,claimId,id) values (?,?,?)
Hibernate 7.4 inserts the history row, but the specification does not promise it, and another provider or version may skip it or fail. Save the history row in the service method, or use a Hibernate event listener from section 5, which is meant for this kind of work.
4.3. Changing the Entity in a Post Callback
A change made in @PostPersist with an IDENTITY id makes the entity dirty after its INSERT, so the commit runs a second UPDATE.
claim.setDescription(claim.getDescription() + " #" + claim.getId());
// insert into ExpenseClaim ...
// commit: @PreUpdate, update ExpenseClaim set ..., @PostUpdate
// description in db = Parking #3
A change made in @PostUpdate comes after the last flush of the transaction, so the change is lost and the description stays “Train ticket”. Set field values in the Pre callbacks; they go into the same INSERT or UPDATE.
5. Hibernate Event Listeners
Jakarta Persistence callbacks cover seven events. Hibernate has its own event system with many more event types (EventType.LOAD, EventType.FLUSH, EventType.POST_COMMIT_INSERT and others), and Hibernate calls the JPA callbacks from it. For example, the stack trace in section 4.1 shows DefaultFlushEntityEventListener calling @PreUpdate through CallbackRegistryImpl. A Hibernate listener implements an interface such as PostCommitInsertEventListener and is registered in the EventListenerRegistry.
The POST_COMMIT_INSERT event fills a gap of the JPA callbacks. It runs after the transaction commits, so a notification never goes out for a row that was rolled back.
public class ClaimCommitListener implements PostCommitInsertEventListener {
@Override
public void onPostInsert(PostInsertEvent event) {
if (event.getEntity() instanceof ExpenseClaim claim) {
CallbackLog.add("ClaimCommitListener committed claim " + claim.getId());
}
}
@Override
public void onPostInsertCommitFailed(PostInsertEvent event) {
CallbackLog.add("ClaimCommitListener commit failed for " + event.getId());
}
@Override
public boolean requiresPostCommitHandling(EntityPersister persister) {
return true;
}
}
An Integrator registers the listener while Hibernate builds the SessionFactory. Hibernate finds the integrator through the Java ServiceLoader, so we list it in a services file.
public class ClaimEventsIntegrator implements Integrator {
@Override
public void integrate(Metadata metadata, BootstrapContext bootstrapContext,
SessionFactoryImplementor sessionFactory) {
sessionFactory.getEventListenerRegistry()
.appendListeners(EventType.POST_COMMIT_INSERT, new ClaimCommitListener());
}
}
com.howtodoinjava.hibernate.callbacks.ClaimEventsIntegrator
The listener’s line comes after the commit, after all JPA callbacks.
ExpenseClaim @PostPersist id=1
-- persist() returned, id = 1, status = SUBMITTED
-- commit
ClaimCommitListener committed claim 1
When the transaction rolls back instead, Hibernate calls onPostInsertCommitFailed(). The old integrate(Metadata, SessionFactoryImplementor, SessionFactoryServiceRegistry) method no longer exists in Hibernate 7. The method gets a BootstrapContext instead, and the registry comes from sessionFactory.getEventListenerRegistry(). For changing or vetoing SQL operations per session, Hibernate also offers interceptors.
| JPA callback | Hibernate event listener | |
|---|---|---|
| Defined by | Jakarta Persistence | Hibernate only |
| Events | 7 | Many, including flush, load, after commit |
| Registered with | Annotations or orm.xml | EventListenerRegistry, usually from an Integrator |
| Gets | The entity | An event object with the entity, id, state and session |
| Use it for | Entity defaults, checks, simple notifications | Auditing, after-commit work, framework code |
6. JPA Lifecycle Callback FAQs
6.1. Should I Use @CreationTimestamp or @PrePersist?
Both fill a timestamp on insert. @CreationTimestamp and @UpdateTimestamp are Hibernate annotations that need no method.
@CreationTimestamp
private LocalDateTime submittedAt;
@UpdateTimestamp
private LocalDateTime updatedAt;
They behave differently from the ExpenseClaim callbacks.
| @PrePersist / @PreUpdate | @CreationTimestamp / @UpdateTimestamp | |
|---|---|---|
| Value set | Inside persist() | At flush, when the INSERT runs |
| After persist(), before flush | submittedAt is set | submittedAt is null |
| updatedAt after the first insert | null (only @PreUpdate sets it) | Set on insert too |
| Other logic (status, checks) | Yes | No, timestamps only |
| Portable | Yes, Jakarta Persistence | Hibernate only |
Use the annotations for plain timestamps and @PrePersist when the value depends on other fields or the code needs it right after persist().
6.2. Why Is My @PreUpdate Method Not Called?
@PreUpdate runs only when the flush finds a changed field. Two common cases skip it.
- The setter writes the same value, for example setAmount(new BigDecimal(“40.00”)) on a claim of 40.00, so Hibernate sends no UPDATE and calls no callback.
- A bulk JPQL update changes rows in the database without loading entities and skips all callbacks.
int updated = em.createQuery("update ExpenseClaim c set c.status = :s where c.id = :id")
.setParameter("s", ClaimStatus.APPROVED)
.setParameter("id", id)
.executeUpdate(); // update ExpenseClaim ec1_0 set status=? where ec1_0.id=?
// 1 row, no @PreUpdate, no @PostUpdate, updatedAt not set
The same applies to bulk delete queries and @PreRemove.
6.3. Does @PostLoad Run for Every Row of a Query?
Yes, once for each entity the query loads into the persistence context. The methods em.find() and em.refresh() call it as well. An entity that is already managed comes from the persistence context and does not get @PostLoad again.
ExpenseClaim lunch = em.find(ExpenseClaim.class, id); // @PostLoad
em.refresh(lunch); // @PostLoad
List<ExpenseClaim> all = em.createQuery("select c from ExpenseClaim c", ExpenseClaim.class)
.getResultList(); // @PostLoad for every other claim, not for lunch
6.4. Does @PrePersist Run on merge()?
Yes, when merge() creates a new entity. Hibernate copies the state to a new managed instance and calls @PrePersist on that copy, not on the object we passed.
ExpenseClaim snacks = new ExpenseClaim("Snacks", new BigDecimal("12.00"));
ExpenseClaim managed = emf.callInTransaction(em -> em.merge(snacks)); // @PrePersist
LocalDateTime managedTime = managed.getSubmittedAt(); // set
LocalDateTime snacksTime = snacks.getSubmittedAt(); // null
7. Conclusion
The seven callback annotations run code at fixed points of an entity’s life. The callbacks @PrePersist, @PreRemove and @PostLoad run inside the call, and the others run around the SQL at flush time, with @PostPersist moving into persist() for IDENTITY ids.
We put entity-specific logic on the entity and shared logic in @EntityListeners classes or default listeners. Callbacks stay free of queries and EntityManager calls, and a Hibernate event listener covers the extra events, such as after commit.
8. References
- Jakarta Persistence 3.2 specification: Entity Listeners and Callback Methods
- PrePersist JavaDoc (Jakarta Persistence 3.2)
- EntityListeners JavaDoc (Jakarta Persistence 3.2)
- Hibernate ORM 7.4 User Guide: Jakarta Persistence Callbacks
- Hibernate ORM 7.4 User Guide: Native Event System
- CreationTimestamp JavaDoc (Hibernate 7.4)
Happy Learning !!