Hibernate doesn't save changes to the database immediately — it accumulates them inside the session and sends them at the right moment. Understanding exactly when that happens and how to protect against concurrent changes is what this article is about.
While the transaction runs, the change lives only in the session's memory. It reaches the database on flush — and not as a bare UPDATE, but with a condition on the version. The first flush matches the condition and the version becomes two; the second one no longer matches, the database answers "0 rows affected", and Hibernate turns that zero into an OptimisticLockException.
What a transaction boundary is and why it matters
When you call entityManager.persist(entity) or change a field on an already-loaded entity, nothing is sent to the database yet. Hibernate records the changes in the persistence context (also known as the "first-level cache" — more about it in the article on the persistence context). Everything reaches the database only on flush.
A transaction is a boundary within which Hibernate guarantees that all changes are sent together — or not at all, if it's rolled back. A typical setup in Spring:
@Transactional
public void transferFunds(long fromId, long toId, BigDecimal amount) {
Account from = entityManager.find(Account.class, fromId);
Account to = entityManager.find(Account.class, toId);
from.debit(amount);
to.credit(amount);
// flush happens automatically before commit
}
Spring opens a transaction on entering the method and commits or rolls it back on exit. Hibernate sees the end of the transaction and flushes — it sends the accumulated UPDATE/INSERT statements to the database.
When exactly flush happens
By default Hibernate runs in FlushMode.AUTO. This means:
- Before the transaction commit.
- Before executing a JPQL/SQL query, if Hibernate detects that "dirty" (changed) entities could affect the query result.
The second point is a frequent source of surprise:
@Transactional
public List<Order> updateAndSearch(long orderId, String newStatus) {
Order order = entityManager.find(Order.class, orderId);
order.setStatus(newStatus); // the entity became "dirty"
// Hibernate will flush BEFORE this query,
// because the query touches the Orders table
return entityManager.createQuery(
"SELECT o FROM Order o WHERE o.status = :s", Order.class)
.setParameter("s", newStatus)
.getResultList();
}
Hibernate sees that the uncommitted change to order.status affects the query result and flushes ahead of time. Useful, but it sometimes leads to unexpected UPDATE statements in the middle of a method.
Other available modes:
| FlushMode | When flush happens |
|---|---|
AUTO (default) | before commit and before queries (when needed) |
COMMIT | only before commit |
ALWAYS | before every query (a Hibernate mode, not JPA) |
MANUAL | only on an explicit flush() call (a Hibernate mode, not JPA) |
COMMIT fits a transaction that runs many read queries and changes things only at the end.
Optimistic locking with @Version
Imagine: two users open a product card at once, both see quantity = 10, both decrease it by 1 and save. The database ends up with 9 instead of 8. This is a lost update.
Optimistic locking solves this problem without physically locking a row in the database. The idea: add a version field to the entity that Hibernate checks and increments on every update.
@Entity
public class Product {
@Id
@GeneratedValue
private Long id;
private String name;
private int quantity;
@Version
private int version; // Hibernate manages this field itself
}
When Hibernate sends an UPDATE, it adds the current version to the condition:
UPDATE product
SET quantity = 9, version = 2
WHERE id = 1 AND version = 1;
If another thread already updated the record (the version in the database is already 2), the WHERE condition finds no row — the UPDATE affects 0 rows, and Hibernate throws an OptimisticLockException.
The same check is easy to watch without a database: the row sits in a plain Map, and an "update" goes through only when the version matches.
live example
import java.util.HashMap;
import java.util.Map;
public class VersionCheck {
static final Map<String, Integer> row = new HashMap<>(Map.of("quantity", 10, "version", 1));
static int update(int quantity, int expectedVersion) {
if (row.get("version") != expectedVersion) {
return 0;
}
row.put("quantity", quantity);
row.put("version", expectedVersion + 1);
return 1;
}
static void show(String who, int rows) {
System.out.println(who + ": rows affected " + rows + ", database now " + row);
}
public static void main(String[] args) {
int quantityA = row.get("quantity"), versionA = row.get("version");
int quantityB = row.get("quantity"), versionB = row.get("version");
show("A", update(quantityA - 1, versionA));
show("B", update(quantityB - 1, versionB));
show("B after re-reading", update(row.get("quantity") - 1, row.get("version")));
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The second call affects zero rows — that is the version conflict; a retry after re-reading goes through.
It is easy to put the try/catch in the wrong place. Changing an entity writes nothing on its own: the UPDATE with the version check leaves on flush. The method below has no query and no explicit flush(), so the flush happens only on commit — already after the @Transactional method returns, and a catch block inside it never fires.
The conflict has to be caught outside the transaction:
@Transactional
public void decreaseQuantity(long productId, int delta) {
Product product = entityManager.find(Product.class, productId);
product.setQuantity(product.getQuantity() - delta);
} // <-- the UPDATE leaves here, and the version is checked
// calling code: the retry sits outside the transaction
@Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3)
public void decreaseQuantityWithRetry(long productId, int delta) {
inventory.decreaseQuantity(productId, delta);
}
Spring wraps the JPA OptimisticLockException into its own ObjectOptimisticLockingFailureException — that is the one to catch.
Pessimistic locking: SELECT FOR UPDATE
Sometimes optimistic locking isn't a good fit. You're debiting money from an account and don't want to work with a value someone may be changing right now. Then you use pessimistic locking — the row is physically locked for the whole transaction.
In JPA this is done through LockModeType:
@Transactional
public void reserveFunds(long accountId, BigDecimal amount) {
Account account = entityManager.find(
Account.class,
accountId,
LockModeType.PESSIMISTIC_WRITE // → SELECT ... FOR UPDATE
);
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException();
}
account.setBalance(account.getBalance().subtract(amount));
}
Hibernate translates PESSIMISTIC_WRITE into SELECT ... FOR UPDATE. Any other transaction that tries to lock the same row waits.
Available options:
| LockModeType | What it does |
|---|---|
PESSIMISTIC_READ | SELECT ... FOR SHARE — others can read but not write |
PESSIMISTIC_WRITE | SELECT ... FOR UPDATE — exclusive lock |
PESSIMISTIC_FORCE_INCREMENT | FOR UPDATE + forcibly increments @Version |
How PostgreSQL implements these locks at the MVCC level — in the article on PostgreSQL locks.
Optimistic vs pessimistic: when to choose which
Optimistic (@Version) fits when:
- Conflicts are rare (most operations don't overlap).
- A "the data changed, please try again" message is acceptable.
- The load is high — physical locks would become a bottleneck.
Pessimistic (PESSIMISTIC_WRITE) fits when:
- Conflicts are frequent and costly (financial operations, inventory).
- Two threads must not work with the same object at once.
- Transactions are short — the lock won't be held for long.
Mixing both approaches is fine: different entities call for different strategies.
In short
- Hibernate doesn't send changes to the database instantly — they accumulate in the persistence context and leave on flush.
- By default, flush happens before commit and before JPQL queries that could be affected by "dirty" entities (
FlushMode.AUTO). @Versionadds a version field: Hibernate checks it on everyUPDATEand throwsOptimisticLockExceptionon a conflict.LockModeType.PESSIMISTIC_WRITElocks a row viaSELECT FOR UPDATE— other transactions wait.- Optimistic locking is for rare conflicts and high load; pessimistic is for frequent conflicts and critical operations.
What to read next
- Persistence context and the entity lifecycle — how Hibernate tracks changes within a session.
- Caching in Hibernate — first- and second-level cache, and when to use each.
- Transactions in Spring: @Transactional from the inside — how Spring manages transaction boundaries and what happens with nested calls.
- Locks in PostgreSQL — MVCC, isolation levels, and
SELECT FOR UPDATEat the database level.