In a code review exercise, I ran into a pattern where every entity update created a new object, copied all the fields over, and called save(). It works. But code written by people fluent in JPA just changes fields on the fetched object and never calls save(). I couldn’t explain precisely why that’s allowed, so I went through the official references.
An entity returned by findById is in the managed state. JPA keeps a snapshot of its state at fetch time, compares it at flush, and automatically generates an UPDATE for any changed fields. This is called dirty checking. Whether to call save() turned out to be not a style choice but a question of which state the entity is in.
Three Entity States
A JPA entity’s state is defined by its relationship to the persistence context. Three states come up most often.
- Transient — right after
new. The persistence context doesn’t know about it, so nothing is tracked. - Managed — after a fetch or a persist. The persistence context tracks it.
- Detached — after the context closes. Once managed, no longer tracked.
stateDiagram-v2
[*] --> Transient: new
Transient --> Managed: persist
[*] --> Managed: find / JPQL query
Managed --> Detached: persistence context closes
Detached --> Managed: merge
Dirty Checking
The persistence context keeps a snapshot of each managed entity’s state at fetch time. At flush, it compares the current state against the snapshot and generates an UPDATE for each entity that changed. With the default settings, flush happens right before transaction commit and before JPQL queries.
@Transactional
public void reserve(String slotId, String userId) {
Slot slot = slotRepository.findById(slotId).orElseThrow();
slot.reserve(); // changing the field is all it takes — UPDATE goes out at commit
reservationRepository.save(new Reservation(slotId, userId));
}
Change state through a domain method like slot.reserve() and there is no separate save code. This is where the copy-all-fields pattern from the review becomes awkward. Copying fields one by one is exactly where I found a bug in the review — one value carried over wrong. Modifying the managed entity directly leaves only the changed fields visible in the code.
What save() Does
Spring Data JPA’s save() is not a command to run SQL. The implementation first decides whether the entity is new. The default detection checks whether the @Version field or the id is null; if new, it calls persist, otherwise merge. Persist is a request to register a transient object with the persistence context; merge is a request to copy a detached object’s state onto the managed instance with the same id. In both cases, the actual SQL comes from flush.
This is also why the “new object + save” pattern from the review worked. A freshly created object carrying an existing id fails the isNew check and goes through merge. If the managed instance to copy onto isn’t in the context, merge fetches it with one more query. The result is the same, but compared with modifying the fetched entity directly, there’s one extra detour.
Persistence Context Lifetime
All of this automatic behavior rests on two premises: the entity is managed, and a flush happens inside a transaction. In Spring, the persistence context is transaction-scoped by default, and the flush that pushes changes to the database happens right before commit. Change an entity’s field outside a transaction and nothing reaches the database. There is no transaction-scoped context to track the change and turn it into an UPDATE.
So “why did it save when I never called save()?” and “why didn’t it save when I changed the field?” are the same question. Both answers are in the persistence context: whether the entity was managed, and whether that context flushed inside a transaction.
Summary
What decides how a change is saved is not whether save() gets called but which state the entity is in. A fetched managed entity needs only a field change for dirty checking to produce the UPDATE; a transient object is registered through persist and a detached one through merge. The copy-all-fields pattern from the review wasn’t wrong code — it was code taking one extra trip through merge. Being able to explain that difference is what I took away from this exercise.
Official docs referenced: Spring Data JPA — Persisting Entities, Hibernate ORM User Guide — Flushing