1. Exact callback timing
We already see callbacks as lifecycle hooks, not as a hidden service layer. Now it is important to separate the annotation name from the real timing.
When you read about callbacks for the first time, an appealing illusion appears: `@PrePersist` means right before INSERT, `@PostUpdate` means right after UPDATE, everything is linear. In Hibernate this is not a movie with a frame-by-frame script, but a unit of work mechanism where a lot is decided on flush. So “before/after” must be understood not philosophically, but in engineering terms — otherwise you will not be able to explain the SQL log, unexpected updates, or sudden transaction rollbacks.
Imagine a simple situation from our domain. You create a PurchaseOrder, set createdAt, and save it. At some point you want to guarantee that createdAt is always filled in. In an ideal world you add @PrePersist, and life is wonderful. In the real world you need to understand two things: a callback is not a separate transaction, it runs within the current one; and it does not have to fire in the same millisecond when you called persist() or repository.save().
That is why in the deep-dive course we do not settle for the wording “well, it is somewhere near insert”. We capture a more useful idea: a callback is tied to the lifecycle of a managed entity inside the persistence context, not to a specific line of code in the service layer. For the update path this connection is especially noticeable on flush, because that is where dirty checking decides whether SQL is needed. For persist/remove/load the picture is also lifecycle-based, but it should not be reduced to one universal millisecond “exactly on flush”.
2. Callbacks in the persist → flush → commit chain
It is very convenient, and it saves nerves, to keep in mind that flush is the point where SQL becomes visible and where the update path feels especially tangible. Hibernate first accumulates changes in the persistence context, then at flush turns them into SQL, and only then does the transaction commit. But not all callbacks should be thought of as one and the same “flush-only” mechanism: @PostLoad lives on loading, @PrePersist/@PreRemove relate to entity lifecycle operations, and @PreUpdate/@PostUpdate are indeed more strongly tied to dirty checking and flush.
Below is a simplified diagram of one typical insert path. It does not aim for academic completeness, but as a “map of the terrain” it works great:
sequenceDiagram
participant S as "Service (@Transactional)"
participant EM as EntityManager
participant PC as "Persistence Context"
participant DB as PostgreSQL
S->>EM: "persist(order)"
EM->>PC: register as managed
Note over PC: persist != immediate INSERT
S->>EM: flush / commit
Note over PC: Typical moment when the insert path reaches SQL
PC->>PC: @PrePersist
PC->>DB: INSERT purchase_order ...
DB-->>PC: "generated values (for example, id)"
PC->>PC: @PostPersist
This picture leads to a very practical consequence: if you want a small bit of automation like createdAt = now(), then @PrePersist is the logical place. But keep this diagram specifically as a map of the insert path, not as a universal formula for all annotations. Providers and id generation strategies have nuances, so it is more useful to remember not one “correct second”, but the dependency itself: a lifecycle hook is part of an operation on the entity, while SQL may materialize later than the persist() call.
To make it even clearer, let us fix a tiny table, no mysticism, just the basics:
| Annotation | What it is tied to | What to keep in mind |
|---|---|---|
| @PrePersist / @PostPersist | the path of inserting a new entity | “insert pipeline”, but not “magic right in the persist() line” |
| @PreUpdate / @PostUpdate | the path of updating a dirty entity | “dirty checking decided that UPDATE is needed” |
| @PreRemove / @PostRemove | the deletion path through remove() | “the entity is removed as an entity”, not as bulk SQL |
| @PostLoad | the materialization path after loading | “after SELECT/refresh”, not for projections |
3. @PrePersist and @PostPersist: the path of inserting a new entity
@PrePersist and @PostPersist look like the kindest callbacks, because they are often used for technical fields: createdAt, sometimes createdBy, sometimes internal flags. But even here it is useful to stay careful: the callback is invoked for a specific entity, within the same transaction, and any exception from it can lead to a rollback. So this is not a “decorator”, but part of the write scenario.
In our Commerce Persistence Lab, the natural candidate is PurchaseOrder.createdAt. The service may forget to set the field (we are human, not the compiler), and the callback can provide a safety net. A good subtlety is not to overwrite the value if it is already set, for example by a test or a migration import.
A mini example inside the entity, short and clear:
import jakarta.persistence.Entity;
import jakarta.persistence.PrePersist;
import java.time.Instant;
@Entity
public class PurchaseOrder {
// Technical field: usually set once when the order is created.
private Instant createdAt;
@PrePersist
void initCreatedAt() {
// The callback should be local and predictable: no database queries and no "business magic".
if (createdAt == null) createdAt = Instant.now();
}
}
Notice the style: the method is small, does one thing, and does not reach outside. Such a callback can be explained in one sentence: “If the order is new and createdAt is not set, set it.”
Now the important practical difference between Pre and Post. In @PostPersist you can already rely on the insert having actually happened for the current lifecycle path. Sometimes that matters if you have values that appear only after insertion. The most common case is a generated identifier. We are not turning this course into a debate about id strategies, but the idea is useful: do not tie these callbacks to one universal millisecond. It is much safer to keep the boundary in mind: @PrePersist is before the insertion happens, @PostPersist is after it.
And one more thing that beginners often discover in practice: a callback does not replace proper business initialization. For example, setting a business order status in @PrePersist is technically possible, but it is already a risk zone, because status is part of the business scenario, not “utility dust”.
4. @PreUpdate/@PostUpdate and dirty checking
@PreUpdate is a favorite source of mysterious stories like “I only read the entity, and somehow it got updated.” Usually the reason is not the callback, but the fact that dirty checking and flush work independently of your emotions. @PreUpdate fires when Hibernate decided: “This managed entity is dirty, we need to do UPDATE.” And here it is important to remember: you may not call save(), but the update will still go out on flush if the object changed. This is exactly where flush-centric intuition is useful: the update is born not from the save() call itself, but from dirty checking on flush.
A technical, almost canonical use case is updatedAt. In other words: “Since we are updating it, let us also update the timestamp.” It is more convenient to keep this in a listener so that you do not duplicate the same code across entities.
Mini example of a listener method, with exactly one responsibility:
import jakarta.persistence.PreUpdate;
import java.time.Instant;
public class InventoryMetadataListener {
@PreUpdate
public void touch(InventoryItem item) {
// Called right in Hibernate's lifecycle pipeline: we simply update the technical field.
// Important: no database queries and no logic that requires external dependencies.
item.setUpdatedAt(Instant.now());
}
}
And the entity itself, where the listener is attached:
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import java.time.Instant;
@Entity
@EntityListeners(InventoryMetadataListener.class)
public class InventoryItem {
// Technical field for auditing: updated automatically before UPDATE.
private Instant updatedAt;
// The listener uses the setter: this is an intentional "write point" for updatedAt.
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
}
Notice that there is no “magic query” to the database, no EntityManager calls. The listener simply changes a field on the same entity.
Now an important boundary from the plan for today: direct bulk update operations are not required to trigger callbacks. If you do something like UPDATE Product p SET p.status = ... through a JPQL bulk update, Hibernate bypasses managed entities and their lifecycle. This is a separate performance topic, but as a counterexample to callbacks it is important to understand now: callbacks are tied to entity lifecycle, while bulk SQL is a different mechanism.
Compare the two situations as an idea, not as a “recipe of the day”:
// Entity lifecycle: the object goes through managed state, callbacks are possible.
entityManager.merge(product);
// Bulk update: the query goes directly to the database, callbacks may not fire.
entityManager.createQuery("""
update Product p set p.name = :name where p.id = :id
""").executeUpdate();
If you build a critical rule on @PreUpdate (for example, “before any update, always do X”), then bulk update instantly becomes a loophole. That is why today we keep the thought in place: a callback is for small technical automation, not for rules on which business correctness depends.
5. @PreRemove/@PostRemove and delete variants
Deletion is a tricky area, because the word “delete” in Hibernate means at least two different actions. First — delete an entity as an object: entityManager.remove(managedEntity) or repository.delete(entity). Second — delete rows with a bulk query: delete from ... where .... And these two realities are not required to pass through lifecycle callbacks in the same way. This is where many projects break when they try to hide something “important” in @PreRemove.
Strictly within the topic, @PreRemove and @PostRemove belong to the deletion path of an entity through the entity removal mechanism. That is, when Hibernate knows it is deleting a specific managed object and takes it through the corresponding stage. The SQL DELETE may go out at flush, but the hook itself is better thought of as part of the remove lifecycle, not as a general “hook near commit”.
Mini example, not a recommendation to “always do it this way”, but a demonstration of the point:
import jakarta.persistence.Entity;
import jakarta.persistence.PreRemove;
@Entity
public class Category {
@PreRemove
void beforeRemove() {
// Important: this will fire on remove(entity), but it is not a guarantee for bulk delete.
// Only local actions on the entity itself are appropriate here.
// Business magic and database queries are a bad idea.
}
}
Now why this is often a bad place for important logic. Because it is enough for someone to write a bulk delete (or a Spring Data @Modifying delete query), and your callbacks stop being a guarantee. In this sense, @PreRemove is an excellent tool for very local actions, but a poor foundation for “mandatory business logic”.
In our project we already discussed soft delete as a separate mechanism. That is also a good example of the boundary: if an entity is not physically deleted, but marked as deleted (@SoftDelete or a flag), then the “remove lifecycle” may not be your normal path at all. And in that case @PreRemove turns into a conditional “rare path” on which you cannot hang semantic weight.
6. @PostLoad: loading and transient fields
@PostLoad is a special callback: it is not about writing, but about reading. It fires after Hibernate has loaded the entity from the database (or after refresh()), and the object is already materialized. This is a convenient point if you want to prepare a transient field for UI/logging/debugging, avoid storing it in a table, and stay out of dirty checking.
A very typical catalog example is a “pretty label” for Product: combine sku and name into one field, but do not store it as a separate column (otherwise you will start maintaining the consistency of “two truths”).
Mini example:
import jakarta.persistence.Entity;
import jakarta.persistence.PostLoad;
import jakarta.persistence.Transient;
@Entity
public class Product {
private String sku;
private String name;
@Transient
private String displayLabel; // Not stored in the DB, used only in memory.
@PostLoad
void buildLabel() {
// Build a "pretty" field from already loaded data.
// Do not make queries here and do not walk the lazy graph.
displayLabel = sku + " | " + name;
}
}
Here it is important to notice three practical things right away. First, displayLabel is marked with @Transient, so Hibernate will not try to save it to the database. Second, @PostLoad should not become a “mini service”: if you start making queries there or walking associations, you risk triggering lazy loads and extra SQL, and then wonder why a simple findById() suddenly makes 12 queries. Third, @PostLoad does not fire “just because you called a method”. It is tied to a real load event, and if the object is already in the persistence context (first-level cache), a repeated find() may return the same object without a new load event.
7. Callback method contract
The word “contract” is not there for decoration. Hibernate invokes callbacks via reflection and expects your method to have the correct signature. If you accidentally add a parameter, return a value, or make the method static, that is not “slightly wrong”, it is “the provider cannot execute the contract correctly”. At best you will catch an error at application startup, at worst at runtime at the moment of flush (and that is traditionally the most “favorite” moment for failures, because an entire transaction fails).
The contract for entity-level callbacks, meaning a method inside the entity, is very simple: the method is void, with no arguments. Visibility may be as narrow as private, but in a training project it is better to make it package-private or protected, so it reads clearly. The contract for listener methods: the method is void, with exactly one argument — the entity (or a superclass), and the listener must be a regular class with a no-arg constructor and no state.
To pin this down, here is a comparison table:
| Where the method is | Signature | Who calls it | Typical style |
|---|---|---|---|
| Inside the entity | void method() | Hibernate | short, local, no parameters |
| In a listener | void method(Entity e) | Hibernate | stateless class, one parameter |
Now about exceptions. If a callback throws a RuntimeException, it almost always ends with a rollback of the current transaction. From Hibernate's point of view, that is logical: you said “this entity is in the wrong state at this lifecycle point”, Hibernate cannot continue writing to the database as if nothing had happened. So you can throw an exception from a callback only if you really want a “fail fast” behavior and understand the consequences.
And the most important limitation from today’s plan: inside a callback you should not do EntityManager and Query level operations. You can technically try, especially if you have somehow passed in an EntityManager, but this almost always means you have crossed the boundary of what callbacks are for. We will discuss the reason in detail in the next section.
8. Queries inside callbacks and flush problems
The rule “do not call EntityManager and Query inside a callback” looks pedantic until you run into a domino effect: the callback starts on flush, inside it you run a query, the query may trigger a flush, the flush triggers the callback again... and hello recursion, unexpected SQL, and rare errors that reproduce only on a full moon.
In schematic form it looks like this:
flowchart TD
A[Flush started] --> B["@PreUpdate callback"]
B --> C[Inside the callback we do a query]
C --> D[Query requires correct data]
D --> E[Hibernate decides: flush before query is needed]
E --> B
In real life this may not turn into an infinite loop, but it can very well give you an unintuitive SQL order, extra flushes, and sometimes concurrency or locking issues. It is especially unpleasant near our topics on optimistic/pessimistic locking: you think you are holding the lock briefly and predictably, but the callback suddenly performs an extra query and lengthens the critical section.
Here is a mini anti-example, commented out so that you are not tempted to “test it in production”:
import jakarta.persistence.PreUpdate;
public class ProductListener {
@PreUpdate
public void beforeUpdate(Product p) {
// Bad idea: the callback often runs during flush, and a query may trigger flush again.
// Even if you "somehow" obtained an EntityManager, this breaks SQL predictability and operation order.
// entityManager.createQuery("select ...").getResultList();
}
}
If your action needs data from the database or needs to affect other entities, that is almost certainly service logic already. A callback is not the place for orchestration. It should “straighten the entity’s collar”, not “organize the wedding of three aggregates and two repositories”.
Useful nuances: projections, bulk, and multiple flushes
There are several nuances with lifecycle callbacks that usually show up not in a textbook, but as a strange bug. That is exactly why it is better to discuss them in advance, while the project is still small and educational, not a “production monolith with 400 tables”.
@PostLoad works on entity loading, not on projection-based reads. If you read a product list through a DTO projection, as we actively did in the read-model module, there will be no @PostLoad: you are not loading an entity, you are creating a DTO. That is normal, you just should not expect callbacks to do what they never promised.
Bulk update/delete (JPQL update/delete, Spring Data @Modifying queries, native SQL) bypass the normal managed-entity lifecycle. Therefore callbacks for such operations are not a universal “hook layer”. If you build a critical rule like “always do X before deleting”, and then someone writes a bulk delete, the rule stops being a rule. That is why we keep repeating: callbacks are only for local technical concerns.
And one more subtlety: flush may happen several times within a single transaction. For example, your service makes changes, then executes a JPQL query that overlaps with the changed tables, and Hibernate does a flush before the query. Then at the end of the transaction there will be a commit and another flush if changes remain. If your @PreUpdate writes updatedAt = now(), then with multiple flushes in one transaction you may get several different “updated at” values within one service call. This is not a Hibernate bug, it is a consequence of flush being a separate phase. In a training project this is rarely a problem, but understanding the mechanism is useful so you do not argue with logs later.
And from this mechanism the main architectural cost of callbacks grows. They fire inside the ORM lifecycle, not at the place where you read the service method with your eyes. Therefore any business effect hidden in a callback quickly turns into a hidden side effect: SQL and state change honestly, but the scenario does not show it.
9. Typical lifecycle callback mistakes
Mistake #1: treating @PrePersist as “fires immediately at the moment of persist()/save()”.
This leads to the expectation that after calling persist() the insert has “definitely happened” and you can already rely on a filled id or a database record. In reality persist registers the object in the persistence context, and the INSERT often goes out on flush. Because of that, logic that “checks the fact of insertion” in @PrePersist may turn out to be premature.
Mistake #2: using @PreUpdate and being surprised that it does not fire on bulk update.
When the update is executed by a direct update ... query, Hibernate does not go through managed entities and is not required to run callbacks. If your automation, for example updatedAt, is critical, you cannot design it as “guaranteed always through @PreUpdate” if the codebase allows bulk operations at all.
Mistake #3: breaking the callback method signature and getting a problem at the most “pleasant” moment — on flush.
The most common variants are: add a parameter to an entity-level callback, return a value instead of void, make the method static, or “throw in” a checked exception. Hibernate expects a strict contract, otherwise you get an exception at startup or at the first entity save.
Mistake #4: using @PostLoad as a place for heavy logic and accidentally triggering extra SQL.
If in @PostLoad you access lazy associations, log the entity “as a whole”, or start computing something by walking collections, you can turn a normal read into a bunch of additional selects. @PostLoad is good for building transient fields from already loaded primitives, but dangerous as a place to “walk the graph”.
Mistake #5: trying to call EntityManager/Query inside a callback and getting a recursive flush or an unintuitive SQL order.
This mistake often starts with a noble goal like “I’ll check uniqueness” or “I’ll fetch a bit more data”, and ends with a domino effect: the callback fires during flush, the query triggers flush before query, the SQL order changes, and the transaction fails or behaves unstably. If you need a query, that is almost certainly a service scenario, not a callback.
GO TO FULL VERSION