← Back to the section

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:

  1. Before the transaction commit.
  2. 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:

FlushModeWhen flush happens
AUTO (default)before commit and before queries (when needed)
COMMITonly before commit
ALWAYSbefore every query
MANUALonly 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:

LockModeTypeWhat it does
PESSIMISTIC_READSELECT ... FOR SHARE — others can read but not write
PESSIMISTIC_WRITESELECT ... FOR UPDATE — exclusive lock
PESSIMISTIC_FORCE_INCREMENTFOR 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).
  • @Version adds a version field: Hibernate checks it on every UPDATE and throws OptimisticLockException on a conflict.
  • LockModeType.PESSIMISTIC_WRITE locks a row via SELECT FOR UPDATE — other transactions wait.
  • Optimistic locking is for rare conflicts and high load; pessimistic is for frequent conflicts and critical operations.
  • 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 UPDATE at the database level.