Two users edit the same account at the same time, both save, and one of the edits quietly disappears. Nothing throws, nothing logs, and the balance is simply wrong. This post reproduces that lost update with two real threads, fixes it with @Version, retries the failure, and then compares it with pessimistic locking: waiting, giving up at once with NOWAIT, skipping locked rows for a work queue, and the deadlock you get from locking in opposite order.
Versions and scope. Hibernate ORM 7.4.14.Final (the latest 7.x GA on Maven Central on 2026-10-25), Jakarta Persistence 3.2.0, H2 2.4.240 in memory, JDK 25, JUnit Jupiter 6.0.3. Plain Hibernate, no Spring. Not covered: Spring Data’s@Lockand@Versionsupport. I did not run it, so I make no claims about it here; theLockModeTypevalues below are the Jakarta Persistence ones it builds on. Every lock behaviour below, including theskip lockedandnowaitSQL and the deadlock message, was observed on H2. Other databases differ in what they support and in the exceptions they raise.
The code is one module of the companion repository: hibernate-demo/locking. Every code block and every line of output below is a real file in it, linked under the block. The explanations stay in this post.
The lost update, reproduced
The entity is as plain as it gets: an id, an owner and a balance, with no version column.
package com.ankurm.locking;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
/** No version column: two transactions can silently overwrite each other. */
@Entity
public class Account {
@Id
public Long id;
public String owner;
public long balance;
protected Account() {
}
public Account(Long id, String owner, long balance) {
this.id = id;
this.owner = owner;
this.balance = balance;
}
}
Source: Account.java. Two transactions each read the account (balance 100), then A adds 10 and commits, then B adds 20 and commits. The test forces that order with latches rather than hoping for it. The helper that builds the session factory and records the SQL is Db.java.
start balance 100; transaction A deposits 10, transaction B deposits 20
both read 100 before either writes; A commits first, then B
final balance: 120 (a correct result would be 130)
update statements Hibernate sent:
update Account set balance=?,owner=? where id=?
update Account set balance=?,owner=? where id=?
Output: 01-lost-update.txt. The final balance is 120 instead of 130. Look at the statement: update Account set balance=?,owner=? where id=?. It writes the value B computed from a stale read and nothing checks that the row is still what B read, so A’s deposit vanishes. This ran under H2’s default isolation level, and the isolation level did not prevent it.
Going deeper
- The test class for this and the next two sections: OptimisticTest.java.
- A transaction that reads, computes in Java and writes back is the whole recipe. Anything that does that without a lock or a version has this bug.
Optimistic locking with @Version
Add a @Version field and Hibernate turns every update into a compare-and-set: it increments the version and adds the old value to the where clause. If no row matches, someone else got there first.
package com.ankurm.locking;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
/** Same shape as {@link Account} plus a {@code @Version} column. */
@Entity
public class VersionedAccount {
@Id
public Long id;
public String owner;
public long balance;
@Version
public long version;
protected VersionedAccount() {
}
public VersionedAccount(Long id, String owner, long balance) {
this.id = id;
this.owner = owner;
this.balance = balance;
}
}
Source: VersionedAccount.java. Same race, same order:
same race, entity has @Version
A: committed. B: failed
B exception: jakarta.persistence.OptimisticLockException
caused by: org.hibernate.StaleObjectStateException
final balance: 110, version: 1
update statements Hibernate sent:
update VersionedAccount set balance=?,owner=?,version=? where id=? and version=?
update VersionedAccount set balance=?,owner=?,version=? where id=? and version=?
Output: 02-version-stops-it.txt. A commits and the version moves to 1. B’s statement ends with where id=? and version=?, matches zero rows, and Hibernate raises StaleObjectStateException, which surfaces as the Jakarta Persistence OptimisticLockException. The balance is 110: B’s deposit was refused rather than silently lost.
Retrying
The exception is information, not a bug: the caller must reload and redo the work. Six threads add 1 to one account five times each, retrying with a fresh transaction whenever the version check fails.
Show the retry loop from the test
for (int i = 0; i < threads; i++) {
futures.add(pool.submit(() -> {
for (int d = 0; d < depositsEach; d++) {
while (true) {
try {
db.sessionFactory.inTransaction(s -> s.find(VersionedAccount.class, 1L).balance += 1);
break;
} catch (OptimisticLockException e) {
retries.incrementAndGet();
}
}
}
}));
}
Excerpt of OptimisticTest.java.
6 threads x 5 deposits of 1, each retried on OptimisticLockException
final balance: 30 (expected 30)
version: 30
retries happened: true
retry count in this run: 27 (varies from run to run)
Output: 03-retry-loop.txt. The balance is exactly 30 and the version is 30: every deposit landed once. The retry count is the one number that changes from run to run, which is why the test asserts the balance and version but not the retries. Many threads on one row means many wasted attempts, which is when people reach for pessimistic locking.
Retry only what is safe to repeat. The loop above recomputes everything from a fresh read. If the failed transaction had sent an email or called another service, retrying would repeat that too.
Pessimistic locking: take the lock first
With LockModeType.PESSIMISTIC_WRITE the read itself locks the row. Transaction A takes the lock and holds it for 600 ms; B asks for the same lock.
Show the two transactions from the test
Future<?> a = pool.submit(() -> db.sessionFactory.inTransaction(s -> {
Account acc = s.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE);
locked.countDown();
sleep(600);
acc.balance += 10;
}));
long[] waitedMillis = new long[1];
Future<?> b = pool.submit(() -> {
await(locked);
long start = System.nanoTime();
db.sessionFactory.inTransaction(s -> {
Account acc = s.find(Account.class, 1L, LockModeType.PESSIMISTIC_WRITE);
waitedMillis[0] = (System.nanoTime() - start) / 1_000_000;
acc.balance += 20;
});
});
Excerpt of PessimisticTest.java.
A locks row 1 with PESSIMISTIC_WRITE, holds it 600 ms, deposits 10
B asks for the same lock, then deposits 20
B waited at least 400 ms for the lock: true
final balance: 130 (correct: 130)
select statements Hibernate sent (one per transaction):
select a1_0.id,a1_0.balance,a1_0.owner from Account a1_0 where a1_0.id=? for update
select a1_0.id,a1_0.balance,a1_0.owner from Account a1_0 where a1_0.id=? for update
Output: 04-pessimistic-write.txt. Hibernate adds for update to the select. B’s find blocks until A commits, and then reads the fresh balance, so both deposits survive: 130. There is no exception and no retry, and the cost is that B waited. The test asserts the wait was at least 400 ms and prints only that, because the exact figure is timing noise.
Giving up instead of waiting
Pass Timeouts.NO_WAIT as a find option and Hibernate asks the database not to wait:
A holds the lock; B asks with Timeouts.NO_WAIT
B exception: org.hibernate.exception.LockTimeoutException
caused by org.h2.jdbc.JdbcSQLTimeoutException
H2 message: Timeout trying to lock table "ACCOUNT"; SQL statement:
caused by org.h2.mvstore.MVStoreException
select statements B sent:
select a1_0.id,a1_0.balance,a1_0.owner from Account a1_0 where a1_0.id=? for update nowait
Output: 05-no-wait.txt. The SQL ends with for update nowait and the caller gets Hibernate’s LockTimeoutException at once. On H2 the underlying message says “Timeout trying to lock table”. Handle it like the optimistic failure: tell the user the record is busy, or try again later.
A work queue with SKIP LOCKED
For a table used as a queue you do not want workers to wait for each other or to fail; you want each to take different rows. A query with PESSIMISTIC_WRITE and the lock-timeout hint set to Timeouts.SKIP_LOCKED_MILLI does that. Worker A claims two tickets and keeps its transaction open; worker B claims two.
Show the claim query
private static List<Ticket> claim(org.hibernate.Session s, int n) {
List<Ticket> rows = s.createQuery("from Ticket t where t.status = 'NEW' order by t.id", Ticket.class)
.setMaxResults(n)
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
.setHint("jakarta.persistence.lock.timeout", Timeouts.SKIP_LOCKED_MILLI)
.getResultList();
rows.forEach(tk -> tk.status = "CLAIMED");
return rows;
}
Excerpt of PessimisticTest.java; the entity is Ticket.java.
5 tickets, all NEW. Worker A claims 2 and keeps its transaction open.
Worker B claims 2 with SKIP LOCKED.
A got tickets: [1, 2]
B got tickets: [3, 4]
select statements B sent:
select t1_0.id,t1_0.claimedBy,t1_0.status from Ticket t1_0 where t1_0.status='NEW' order by t1_0.id fetch first ? rows only for update skip locked
Output: 06-skip-locked.txt. A holds tickets 1 and 2, B gets 3 and 4, and the SQL ends with for update skip locked. Ticket 5 is untouched for the next worker. Rows that are locked are skipped, so the result depends on what is in flight at that moment: that is what you want for a queue and wrong for anything that must see every row.
Deadlock, and the rule that prevents it
Locks held while waiting for other locks can form a cycle. Two transactions lock accounts 1 and 2: A takes 1 then 2, B takes 2 then 1. The test makes each hold its first lock before asking for the second. Then the same two transactions again, both locking the lower id first.
opposite lock order (A: 1 then 2, B: 2 then 1)
transactions that failed: 1
jakarta.persistence.PessimisticLockException
caused by org.hibernate.exception.LockAcquisitionException
caused by org.h2.jdbc.JdbcSQLTransactionRollbackException
H2 message: Deadlock detected. The current transaction was rolled back. Details: "ACCOUNT"; SQL statement:
caused by org.h2.mvstore.MVStoreException
same lock order for both (always the lower id first)
transactions that failed: 0
Output: 07-deadlock.txt. With opposite order, H2 detects the cycle, rolls one transaction back and reports “Deadlock detected”; Hibernate wraps it in PessimisticLockException. The other transaction then completes. With one agreed order nothing fails, because the second transaction simply waits for the first. The fix is a rule, not a try/catch: always lock in a fixed order, for example ascending id. Where you cannot control the order, treat the exception like an optimistic failure and retry the whole transaction.
Going deeper
- I only ran H2, which reported the deadlock by name. Other databases may detect it differently, or only through a lock timeout, so check how yours reports it.
Which one should you use?
Rules of thumb drawn from the behaviour above, not from a benchmark:
- Few collisions, short transactions:
@Version. No locks are held while users think, and the retry loop should rarely run. - Many writers on the same rows:
PESSIMISTIC_WRITE. Waiting avoids a retry loop that keeps losing; the cost is held locks, so keep the transaction short. - A table used as a queue:
SKIP LOCKED, where your database supports it. - Several pessimistic locks in one transaction: a fixed lock order.
Not measured here. I did not benchmark throughput. Which approach is faster depends on the collision rate and the database, so measure on your own workload.
Frequently asked questions
How do I prevent lost updates in Hibernate?
Add a @Version field to the entity so a concurrent update fails with an OptimisticLockException, or read the row with LockModeType.PESSIMISTIC_WRITE so the second writer waits. In the tests here the unversioned entity ended at 120 instead of 130; with @Version the second writer failed, and with PESSIMISTIC_WRITE both deposits landed.
What does OptimisticLockException mean?
Another transaction changed the row after you read it. Hibernate’s update ends with ‘where id=? and version=?’, matched no row, and raised StaleObjectStateException, which surfaces as the Jakarta Persistence OptimisticLockException. Reload the entity and redo the work.
What is SKIP LOCKED used for in Hibernate?
It lets several workers take different rows from a queue table. Use a query with PESSIMISTIC_WRITE and the jakarta.persistence.lock.timeout hint set to Timeouts.SKIP_LOCKED_MILLI. On H2 the SQL ended with ‘for update skip locked’ and the second worker received different rows. Support varies by database.
Run it yourself
Clone the repository, go into locking and run ./scripts/run-all.sh (JDK 25 and Maven 3.9). Seven tests run and rewrite the files in output/; each asserts the facts it prints. Build file: pom.xml.
Conclusion
A read-modify-write without a version or a lock loses updates silently, and the isolation level alone did not save us in the test. @Version turns the loss into an exception you can retry; PESSIMISTIC_WRITE makes the second writer wait; NOWAIT and SKIP LOCKED cover the cases where waiting is the wrong answer; and a fixed lock order prevents the deadlocks that pessimistic locks invite.
No Comments yet!