Definition
- @PrePersist -- fires before the INSERT is flushed, used to set creation defaults like timestamps and reference numbers.
- @PostPersist -- fires after the INSERT when the generated ID is available, used for event publishing and logging.
- @PreUpdate -- fires before an UPDATE is flushed, only if Hibernate's dirty checking detected actual changes.
- @PostUpdate -- fires after the UPDATE completes.
- @PreRemove -- fires before the DELETE is flushed, used for cleanup, audit logging, or soft-delete interception.
- @PostRemove -- fires after the DELETE completes.
- @PostLoad -- fires after an entity is loaded from the database, used for computing @Transient derived fields.
- @EntityListeners -- registers external listener classes for cross-cutting callback logic shared across entities.
At National Bank of India, lifecycle callbacks are automatic stamps the system puts on a form at each stage. @PrePersist is the stamp right before a new account form is filed: "set the open date, generate the reference number." @PostPersist is the confirmation stamp after filing: "send the welcome SMS." @PreUpdate stamps just before edits are saved: "record the modification time." @PreRemove stamps before a file is archived: "mark as closed, notify compliance." @PostLoad is what happens the moment a clerk pulls a file from the cabinet: "compute the display balance." Nobody tells the clerk each time; these happen automatically.
Understanding the Concepts
What are JPA lifecycle callbacks?
JPA lifecycle callbacks are methods annotated to run automatically at specific points in an entity's persistence lifecycle. They fire without any manual invocation from service code. This moves cross-cutting persistence logic out of the service layer and into a predictable, centralized place.
- Seven callback annotations cover the full lifecycle: create, update, delete, and load.
- Callbacks can be placed directly on entity methods or in separate listener classes.
- They are called by the JPA provider (Hibernate) during flush or load operations.
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "transactions")
public class Transaction {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(name = "reference", unique = true)
private String reference;
@Column(name = "account_id")
private String accountId;
@Column(precision = 15, scale = 2)
private BigDecimal amount;
@Column(name = "created_at", updatable = false)
private Instant createdAt;
@Column(name = "updated_at")
private Instant updatedAt;
@PrePersist
public void onPrePersist() {
this.createdAt = Instant.now();
this.updatedAt = Instant.now();
this.reference = "TXN-" + UUID.randomUUID().toString()
.substring(0, 8).toUpperCase();
}
@PreUpdate
public void onPreUpdate() {
this.updatedAt = Instant.now();
}
}
Callback firing order for a new transaction:
1. new Transaction() -> just a Java object (transient)
2. em.persist(transaction) -> @PrePersist fires
createdAt = 2024-01-15T10:30:00Z
reference = "TXN-A1B2C3D4"
3. flush / commit -> INSERT INTO transactions ...
4. After INSERT -> @PostPersist fires
Generated ID is now available
How does @PrePersist work?
@PrePersist fires before the INSERT is issued to the database. It runs during the flush cycle, before Hibernate generates the SQL. This is the correct place to set creation defaults, generate reference numbers, validate invariants, or compute initial values.
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "accounts")
public class Account {
@Id
@Column(name = "account_id")
private String accountId;
@Column(name = "holder_name")
private String holderName;
@Column(precision = 15, scale = 2)
private BigDecimal balance;
@Column(name = "status")
private String status;
@Column(name = "created_at", updatable = false)
private Instant createdAt;
@PrePersist
public void setDefaults() {
if (this.status == null) {
this.status = "ACTIVE";
}
if (this.balance == null) {
this.balance = BigDecimal.ZERO;
}
this.createdAt = Instant.now();
}
}
Before @PrePersist:
status = null, balance = null, createdAt = null
After @PrePersist:
status = "ACTIVE", balance = 0.00, createdAt = 2024-01-15T10:30:00Z
SQL:
INSERT INTO accounts (account_id, holder_name, balance, status, created_at)
VALUES ('SAV2001', 'Rajesh Kumar', 0.00, 'ACTIVE', '2024-01-15T10:30:00Z')
How does @PostPersist work?
@PostPersist fires after the INSERT completes and the generated ID is available. This is the correct place to publish domain events, send notifications, or log the creation.
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.logging.Logger;
@Entity
@Table(name = "transactions")
public class Transaction {
private static final Logger LOG =
Logger.getLogger(Transaction.class.getName());
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(name = "account_id")
private String accountId;
@Column(precision = 15, scale = 2)
private BigDecimal amount;
@PostPersist
public void afterInsert() {
LOG.info("Transaction created: id=" + id
+ ", account=" + accountId
+ ", amount=" + amount);
// Generated ID is now available
// Publish domain event: new TransactionCreatedEvent(id)
}
}
Output after INSERT:
INFO: Transaction created: id=42, account=SAV2001, amount=50000.00
How does @PreUpdate work?
@PreUpdate fires before an UPDATE is flushed. An important detail is that it only fires if Hibernate's dirty checking actually detects a change. If nothing changed, no UPDATE is generated and @PreUpdate does not fire.
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
@Entity
@Table(name = "accounts")
public class Account {
@Id
@Column(name = "account_id")
private String accountId;
@Column(precision = 15, scale = 2)
private BigDecimal balance;
@Column(name = "updated_at")
private Instant updatedAt;
@PreUpdate
public void onPreUpdate() {
this.updatedAt = Instant.now();
}
}
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class BalanceUpdateService {
@PersistenceContext
private EntityManager em;
@Transactional
public void credit(String accountId, BigDecimal amount) {
Account account = em.find(Account.class, accountId);
account.setBalance(account.getBalance().add(amount));
// Dirty checking detects balance change
// @PreUpdate fires: updatedAt = now()
// UPDATE SET balance=?, updated_at=? WHERE account_id=?
}
@Transactional
public void noOpUpdate(String accountId) {
Account account = em.find(Account.class, accountId);
// No field changed
// Dirty checking finds nothing to update
// @PreUpdate does NOT fire
// No UPDATE generated
}
}
How does @PostLoad work?
@PostLoad fires after an entity is loaded from the database, whether through find(), a JPQL query, or a collection load. This is the correct place to compute @Transient derived fields.
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "accounts")
public class Account {
@Id
@Column(name = "account_id")
private String accountId;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
@Column(precision = 15, scale = 2)
private BigDecimal balance;
@Column(name = "hold_amount", precision = 15, scale = 2)
private BigDecimal holdAmount;
@Transient
private String fullName;
@Transient
private BigDecimal availableBalance;
@PostLoad
public void computeDerivedFields() {
this.fullName = firstName + " " + lastName;
this.availableBalance = balance.subtract(
holdAmount != null ? holdAmount : BigDecimal.ZERO
);
}
}
After loading account SAV2001:
firstName = "Rajesh", lastName = "Kumar"
balance = 150000.00, holdAmount = 10000.00
@PostLoad computes:
fullName = "Rajesh Kumar"
availableBalance = 140000.00
These fields exist only in Java memory, not in the database.
How do you use @EntityListeners for shared logic?
When the same callback logic applies across many entities, extract it into a separate listener class and register it with @EntityListeners. The listener methods receive the entity as a parameter.
package com.bank.audit;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.logging.Logger;
public class AuditListener {
private static final Logger LOG =
Logger.getLogger(AuditListener.class.getName());
@PrePersist
public void onPrePersist(Object entity) {
LOG.info("Creating: " + entity.getClass().getSimpleName());
}
@PreUpdate
public void onPreUpdate(Object entity) {
LOG.info("Updating: " + entity.getClass().getSimpleName());
}
@PreRemove
public void onPreRemove(Object entity) {
LOG.info("Removing: " + entity.getClass().getSimpleName());
}
}
package com.bank.model;
import com.bank.audit.AuditListener;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "accounts")
@EntityListeners(AuditListener.class)
public class Account {
@Id
@Column(name = "account_id")
private String accountId;
@Column(precision = 15, scale = 2)
private BigDecimal balance;
}
@Entity
@Table(name = "transactions")
@EntityListeners(AuditListener.class)
public class Transaction {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
@Column(precision = 15, scale = 2)
private BigDecimal amount;
}
// Both entities share the same audit logging via AuditListener
What are common mistakes with lifecycle callbacks?
The most common mistakes involve calling EntityManager from within callbacks and placing heavy logic in callback methods.
// WRONG: calling EntityManager inside a callback
@PrePersist
public void onPrePersist() {
// DO NOT DO THIS!
Account existing = em.find(Account.class, this.accountId);
// Triggers a query inside the flush cycle
// Can cause recursion, ConcurrentModificationException,
// or undefined behavior
}
// CORRECT: validate without EntityManager calls
@PrePersist
public void onPrePersist() {
if (this.balance == null) {
this.balance = BigDecimal.ZERO;
}
this.createdAt = Instant.now();
// Only set fields on THIS entity. No queries.
}
// WRONG: sending email in @PostPersist (blocks flush)
@PostPersist
public void afterInsert() {
emailService.sendWelcomeEmail(this.holderName, this.email);
// If email service is slow, the transaction is delayed
// If email fails, the transaction might roll back
}
// CORRECT: publish an event, handle asynchronously
@PostPersist
public void afterInsert() {
DomainEventPublisher.publish(
new AccountCreatedEvent(this.accountId)
);
// Lightweight: just publishes an event
// Async consumer sends the email outside this transaction
}
What is the callback firing order?
The callbacks fire in a specific order depending on the operation. For a complete lifecycle, the order is:
New entity creation:
1. @PrePersist
2. INSERT SQL
3. @PostPersist
Entity load:
1. SELECT SQL
2. @PostLoad
Entity update:
1. @PostLoad (when entity is first loaded)
2. ... (field modifications in Java)
3. @PreUpdate (on flush, if dirty)
4. UPDATE SQL
5. @PostUpdate
Entity deletion:
1. @PostLoad (when entity is loaded)
2. @PreRemove
3. DELETE SQL
4. @PostRemove
With @EntityListeners:
Listener callbacks fire BEFORE entity callbacks.
Example: AuditListener.onPrePersist() -> Account.onPrePersist()
What about @PreRemove and soft deletes?
@PreRemove fires before the DELETE is issued. In banking, physical deletion is rare. Instead, @PreRemove can intercept the delete and convert it to a soft delete by setting a status flag.
package com.bank.model;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "accounts")
public class Account {
@Id
@Column(name = "account_id")
private String accountId;
@Column(name = "status")
private String status;
@Column(name = "closed_at")
private Instant closedAt;
@PreRemove
public void onPreRemove() {
// Log before deletion
System.out.println("Account " + accountId
+ " is being removed. Status: " + status);
// In banking, consider soft-delete instead:
// this.status = "CLOSED";
// this.closedAt = Instant.now();
// But note: @PreRemove cannot PREVENT the delete.
// For true soft-delete, use @SQLDelete annotation
// or handle in the service layer.
}
}
What should a senior engineer know about callbacks?
Senior engineers should understand the limitations and best practices for callbacks in production systems.
- Callbacks must not call EntityManager operations. No queries, no flush, no persist of other entities.
- Keep callbacks lightweight. Heavy operations like email, HTTP calls, or file I/O should be handled asynchronously via events.
- @PreUpdate only fires on dirty entities. If nothing changed, no callback fires and no UPDATE is generated.
- Listener callbacks fire before entity callbacks. Use this ordering to separate cross-cutting concerns from entity-specific logic.
- In Spring Boot, you can use
@RegisterDefaultEntityCallbackListenersto apply listeners globally.
// Summary of callback rules:
// 1. Keep callbacks side-effect-light (set fields, publish events)
// 2. Never call EntityManager from callbacks
// 3. Never throw checked exceptions from callbacks
// 4. Use @EntityListeners for cross-cutting logic
// 5. Use @PostPersist/@PostUpdate + events for heavy async work
// 6. @PreUpdate only fires if entity is actually dirty
Visual Overview
Entity Lifecycle Callbacks:
┌──────────┐ @PrePersist ┌────────┐ @PostPersist ┌──────────┐
│ Transient │ ──────────────> │ INSERT │ ──────────────> │ Managed │
│ (new) │ set defaults │ SQL │ log / event │ │
└──────────┘ └────────┘ └──────────┘
│ │
│ │
│ @PostLoad │
│ <──────────────── (on find / query) │
│ compute @Transient │
│ │
│ @PreUpdate ┌────────┐ @PostUpdate
│ ──────────────> │ UPDATE │ ──────────────>
│ set updatedAt │ SQL │ log / event
│ └────────┘
│
│ @PreRemove ┌────────┐ @PostRemove
│ ──────────────> │ DELETE │ ──────────────>
│ audit / check │ SQL │ cleanup
│ └────────┘
Firing order with @EntityListeners:
┌──────────────┐ ┌──────────────┐
│ Listener │ -> │ Entity │
│ callback │ │ callback │
│ (fires FIRST)│ │ (fires SECOND)│
└──────────────┘ └──────────────┘
Key Takeaway
JPA lifecycle callbacks are automatic hooks that fire at well-defined points in an entity's persistence lifecycle, removing the need to remember to call them from service code. @PrePersist runs before the insert and is where you set creation timestamps and generate reference numbers. @PostPersist runs after the insert when the generated ID exists, making it ideal for logging or publishing domain events. @PreUpdate runs before an update but only if Hibernate's dirty checking detected an actual change.
@PostLoad is the callback developers most often forget. It runs immediately after an entity is loaded from the database, making it the correct place to compute derived @Transient fields like available balance (balance minus hold amount) or full name (first name plus last name). In our National Bank of India transaction service, @PrePersist guarantees every transaction gets a creation timestamp and a unique TXN reference before insertion, eliminating a whole class of bugs where developers forgot to set those fields.
For cross-cutting logic that applies across many entities, move the callback methods into a shared listener class registered with @EntityListeners. The listener's callbacks fire before the entity's own callbacks, providing a clean separation between generic audit logging and entity-specific initialization. The critical rule is that callbacks must stay lightweight and must never call EntityManager to run queries or force a flush, because that can cause recursion inside the flush cycle.
Remember one sentence: lifecycle callbacks fire automatically around INSERT, UPDATE, DELETE, and LOAD so you never forget to set defaults, stamp timestamps, or compute derived fields, but keep them lightweight and never call EntityManager from within them.
---