Skip to main content
Spring Framework · Topic 18 of 25

Optimistic and Pessimistic Locking

Two users load the same order, both edit it, both save. Without protection the second save silently overwrites the first: a lost update. Locking strategies decide how to prevent that.

Definition​

Locking stops two users from silently overwriting each other's changes. Optimistic locking adds a version number and rejects an update if the row changed since you read it. Pessimistic locking locks the row while you work.

Why it exists​

When two people edit the same record, the last save wins and the other change is lost. Locking either detects that conflict or prevents it.

How it works​

Optimistic locking​

Assume conflicts are rare. Do not lock; detect conflicts at write time with a version column.

@Entity
class PurchaseOrder {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;

String status;

@Version
long version;
}

Hibernate adds the version to every update: update purchase_orders set status=?, version=? where id=? and version=?. If zero rows are updated, someone else changed the row and Spring throws ObjectOptimisticLockingFailureException (wrapping JPA's OptimisticLockException).

A test of the scenario above:

PurchaseOrder stale = orders.findById(id).orElseThrow(); // A reads (version 0)
tx.executeWithoutResult(s -> orders.findById(id).orElseThrow().status = "PAID"); // B commits (version 1)
stale.status = "CANCELLED";
assertThatThrownBy(() -> orders.saveAndFlush(stale)) // A's write is rejected
.isInstanceOf(OptimisticLockingFailureException.class);

Handling the failure​

  • UI/API flow: return 409 Conflict (or 412 Precondition Failed with ETags) and let the user reload and retry. Expose the version in the DTO and send it back on update, otherwise the check only covers the short window inside one transaction.
  • Background flow: retry the whole transaction a few times (re-read, re-apply, save). With Spring Retry:
@Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2))
@Transactional
public void addItem(Long orderId, String sku) { ... }

The retry must wrap the transaction (the proxy order matters; the retry advice should be the outer one), not run inside it.

Version and bulk updates​

JPQL bulk updates do not touch @Version unless you write update versioned PurchaseOrder .... Also note that @Version protects only the entity's own row; changes to child collections owned by the entity increment the parent version only when the collection is modified (owned collections are versioned by default).

Pessimistic locking​

Assume conflicts are likely or costly. Take a database lock when reading so others must wait.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from PurchaseOrder o where o.id = :id")
Optional<PurchaseOrder> findForUpdate(@Param("id") Long id);

This issues SELECT ... FOR UPDATE. Other transactions that try to lock or update that row block until the first commits or rolls back. It only works inside a transaction.

ModeMeaning
PESSIMISTIC_READShared lock: others can read, but not write
PESSIMISTIC_WRITEExclusive lock: others cannot lock or write
PESSIMISTIC_FORCE_INCREMENTWrite lock and also bump the version

Set a lock timeout so waiting does not hang forever:

@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))

For work queues use SKIP LOCKED (jakarta.persistence.lock.timeout = -2 in Hibernate) so several workers pick different rows without blocking each other. This is how a polling outbox relay scales (see Outbox).

System design considerations​

  • Return 409 Conflict on optimistic failures and let clients retry with fresh data
  • Set lock timeouts and keep locking transactions short
  • For queues of work use SELECT FOR UPDATE SKIP LOCKED

When to use it, and when not​

Use it when

  • Concurrent edits to the same record are possible (optimistic with @Version)
  • A hot row needs strict serialisation (pessimistic)
  • You can express the change as an atomic UPDATE

Think twice when

  • Contention is very high and optimistic retries would thrash
  • Locks are held across slow operations or remote calls
  • You lock rows in inconsistent order across code paths

Which one to use​

OptimisticPessimistic
Conflict frequencyLowHigh
Cost on happy pathAlmost noneLock held, waiting, reduced concurrency
On conflictException, retryWait (or timeout)
Works across HTTP requestsYes (send the version)No (lock lasts one transaction)
RisksRetry storms under heavy contentionDeadlocks, blocked threads, pool exhaustion

Examples: editing a profile or document uses optimistic. Decrementing a hot inventory counter or allocating a unique ticket number often uses pessimistic locking, or better, an atomic SQL update (update stock set qty = qty - 1 where sku = ? and qty > 0) which needs neither.

Other tools against lost updates​

  • Atomic statements: set counter = counter + 1, and check the affected row count.
  • Unique constraints to enforce "only one" rules; handle the violation.
  • Higher isolation (serializable) with retry on serialization failure.
  • Distributed locks (Redis, ZooKeeper) are for cross-service mutual exclusion, and are not a substitute for database constraints.

Common mistakes​

Deadlocks​

Two transactions each hold a lock the other needs:

The database detects the cycle and aborts one transaction (a deadlock exception). Prevent them by always locking rows in the same order (for example by ascending id), keeping transactions short, and being ready to retry.

  • Using @Version but not sending the version through the API, so conflicts across user sessions go undetected.
  • Retrying inside the failed transaction instead of starting a new one.
  • Long transactions holding pessimistic locks while calling remote services.
  • Locking rows in inconsistent order.
  • Forgetting that bulk JPQL updates bypass versioning and the persistence context.

Interview questions​

1. Optimistic vs pessimistic locking?

Optimistic detects conflicts at commit using a version column and fails/retries; pessimistic prevents conflicts by taking database locks while reading. Optimistic scales better when conflicts are rare.

2. How does @Version work?

Hibernate includes where version = ? in updates and increments it. If no row matches, the entity is stale and an optimistic lock exception is thrown.

3. What is a lost update?

Two transactions read the same data and each writes back changes based on the old value, so one change overwrites the other without anyone noticing.

4. How do you handle ObjectOptimisticLockingFailureException?

Retry the business operation from the start for system flows, or return 409 so a user can reload and resubmit.

5. How do you avoid deadlocks?

Lock in a consistent order, keep transactions short, avoid user interaction inside transactions, and retry on deadlock errors.

6. Does @Transactional alone prevent lost updates?

No. At the default READ_COMMITTED level, two transactions can still read-then-write over each other. You need a version column, locking, atomic updates or stricter isolation.

7. How do you let many workers consume rows from a table without conflicts?

SELECT ... FOR UPDATE SKIP LOCKED so each worker claims a different batch.

Check yourself​

Quick quiz3 questions

1How does optimistic locking detect a conflict?

2When is pessimistic locking appropriate?

3How do you reduce deadlock risk?

Key takeaways​

  • Choose optimistic locking by default; use pessimistic for hot rows with real contention.
  • @Version turns lost updates into detectable exceptions.
  • Always have a retry or conflict-response strategy.
  • Consistent lock ordering and short transactions prevent deadlocks.

Next: SQL tuning and indexing.

Next topicSQL tuning and indexing

Finished this page? Track your progress (saved only in your browser).