JPA Lifecycle Callbacks in Hibernate: @PrePersist and More

The seven JPA lifecycle callbacks in Hibernate 7.4, when each one runs relative to persist(), flush and commit, entity listeners and default listeners in orm.xml, what a callback must not do, and Hibernate’s own event listeners.

logo

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.

State diagram: a new ExpenseClaim becomes managed with persist(), which calls @PrePersist and, after the INSERT, @PostPersist; find(), a query or refresh() load it from the database and call @PostLoad; the flush of a changed entity calls @PreUpdate, runs the UPDATE and calls @PostUpdate; remove() calls @PreRemove and, after the DELETE, @PostRemove; close() or detach() makes it detached without a callback
Every state change has a callback point. @Pre methods run before the operation, @Post methods after the SQL statement.

The seven annotations are in the jakarta.persistence package, and each one runs at its own point.

AnnotationRunsTypical use
@PrePersistInside em.persist(), before the INSERTDefault values, creation time
@PostPersistAfter the INSERTRead the generated id, send a notification
@PreUpdateAt flush, before the UPDATE of a changed entityModification time, validation
@PostUpdateAfter the UPDATENotification
@PreRemoveInside em.remove()Block deletion, clean up references
@PostRemoveAfter the DELETEDelete files that belonged to the row
@PostLoadAfter find(), a query or refresh() loads the entityFill a @Transient field

A callback method can sit in three places, which all use the same annotations but differ in scope.

WhereMethod signatureApplies to
On the entity classvoid onSubmit(), no parametersThat entity only
In a listener class named in @EntityListenersvoid validate(ExpenseClaim claim)Each entity that lists the class
In a default listener declared in orm.xmlvoid 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.

Timeline with three columns, during the call, flush and after commit: persist with an IDENTITY id runs @PrePersist, INSERT and @PostPersist during the call; persist with a SEQUENCE id runs next value and @PrePersist during the call, and INSERT and @PostPersist at flush; a setter runs nothing during the call and @PreUpdate, UPDATE, @PostUpdate at flush; remove runs @PreRemove during the call and DELETE, @PostRemove at flush; find runs SELECT and @PostLoad during the call; a Hibernate post-commit listener runs after commit for both inserts
@PrePersist, @PreRemove and @PostLoad run inside the call. The other callbacks wait for the flush, except @PostPersist with an IDENTITY id.

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 strategyId in @PrePersistINSERT and @PostPersist run
IDENTITYnullInside persist()
SEQUENCEAlready setAt 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.

Three boxes in order: 1. default listeners from META-INF/orm.xml (AuditListener), 2. @EntityListeners in the declared order (ClaimValidationListener, ClaimNotificationListener), 3. entity methods (ExpenseClaim.onSubmit()); below them the real log of em.persist(lunch): AuditListener @PrePersist, ClaimValidationListener validate, ExpenseClaim @PrePersist id=null, the insert, ClaimNotificationListener notify manager, ExpenseClaim @PostPersist id=1
Default listeners run first, and the entity’s own methods run last.

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 callbackWhat happened in Hibernate 7.4
Run a JPQL query in @PreUpdateStackOverflowError, transaction rolled back
Call em.persist() for another entity in @PostPersistWorks: 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 @PostUpdateThe 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 callbackHibernate event listener
Defined byJakarta PersistenceHibernate only
Events7Many, including flush, load, after commit
Registered withAnnotations or orm.xmlEventListenerRegistry, usually from an Integrator
GetsThe entityAn event object with the entity, id, state and session
Use it forEntity defaults, checks, simple notificationsAuditing, 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 setInside persist()At flush, when the INSERT runs
After persist(), before flushsubmittedAt is setsubmittedAt is null
updatedAt after the first insertnull (only @PreUpdate sets it)Set on insert too
Other logic (status, checks)YesNo, timestamps only
PortableYes, Jakarta PersistenceHibernate 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

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.