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.
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 sent 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 closes it (commit or rollback) on exit. Hibernate sees the end of the transaction and performs a flush — 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. Here's an example:
@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 understands that the uncommitted change to order.status affects the query result, so it flushes ahead of time. This is 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 |
MANUAL | only on an explicit flush() call |
COMMIT is sometimes used for optimization in scenarios where many read queries run within a single transaction and the changes come only at the end.
Optimistic locking with @Version
Imagine: two users open a product card at the same time, both see quantity = 10, both decrease it by 1 and save. The database ends up with 9, though it should be 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, which Hibernate automatically 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 managed to update the record (the version in the database is already 2), the WHERE condition won't find the row — the UPDATE will affect 0 rows. Hibernate notices this and throws an OptimisticLockException.
You need to handle this exception explicitly — usually by retrying or notifying the user of the conflict:
@Transactional
public void decreaseQuantity(long productId, int delta) {
try {
Product product = entityManager.find(Product.class, productId);
product.setQuantity(product.getQuantity() - delta);
} catch (OptimisticLockException e) {
// version conflict — another thread got there first
throw new ConcurrentModificationException("The product was modified concurrently", e);
}
}
A short formula: @Version means "check before writing, rather than lock in advance".
Pessimistic locking: SELECT FOR UPDATE
Sometimes optimistic locking isn't a good fit. For example, you're debiting money from an account and don't want to work with a value that someone might change right now. In that case you use pessimistic locking — the row in the database is physically locked for the duration of the 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 at the database level. Any other transaction that tries to lock the same row will wait.
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 |
The details of how PostgreSQL implements these locks at the MVCC level and what happens under concurrent access are covered 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).
- The user can see a "the data changed, please try again" message and that's acceptable.
- The load is high — physical locks would become a bottleneck.
Pessimistic (PESSIMISTIC_WRITE) fits when:
- Conflicts are frequent and costly (financial operations, inventory).
- You can't allow two threads to work with the same object at the same time.
- Transactions are short — the lock won't be held for long.
Mixing both approaches in one application 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.