Optimistic Locking in Hibernate — @Version, Retry Strategies, and Conflict Resolution
by Eric Hanson, Backend Developer at Clean Systems Consulting
The lost update problem
Without concurrency control, two concurrent requests that read and modify the same entity produce a lost update:
Thread A: reads Order(id=1, status=PENDING, total=100)
Thread B: reads Order(id=1, status=PENDING, total=100)
Thread A: updates status to PROCESSING, commits → Order(status=PROCESSING, total=100)
Thread B: updates total to 150, commits → Order(status=PENDING, total=150)
^ Thread A's update is lost
Thread B loaded the entity before Thread A committed. Thread B's update overwrites Thread A's changes. The final state (PENDING, 150) reflects neither thread's intent — status=PROCESSING from Thread A is silently discarded.
Optimistic locking prevents this by detecting when the row has changed between the read and the write.
@Version — the mechanism
Adding @Version to an entity adds a version column. Hibernate includes the version value in every UPDATE statement and checks that the row was updated. If zero rows are updated (because another transaction changed the version), Hibernate throws OptimisticLockException:
@Entity
public class Order {
@Id
private Long id;
@Version
private Long version; // managed by Hibernate — never modify this directly
private OrderStatus status;
private BigDecimal total;
}
The generated UPDATE:
UPDATE orders
SET status = 'PROCESSING', version = 2
WHERE id = 1 AND version = 1
-- If no rows updated (version was already > 1), Hibernate throws OptimisticLockException
The version check is atomic with the update — no separate read is required. The database's row-level locking ensures that the WHERE id = 1 AND version = 1 check and the update happen atomically.
Version column types. Long, Integer, Short are numeric version counters — Hibernate increments on every update. Instant, Timestamp use the modification timestamp — Hibernate sets to current time on update.
Numeric counters are preferred: timestamps have millisecond (or microsecond) granularity — two updates within the same millisecond on the same entity won't be detected. Numeric counters increment regardless of timing.
Never modify @Version directly. Setting order.setVersion(...) manually causes Hibernate to send an incorrect version in the UPDATE, which either incorrectly succeeds or incorrectly fails. The version field is owned by Hibernate.
Handling OptimisticLockException
OptimisticLockException (JPA) / StaleObjectStateException (Hibernate) is thrown when a version conflict is detected. At this point, the transaction has been rolled back — the entity's in-memory state is invalid.
The two responses: retry the operation or report a conflict to the caller.
When to retry. Retry is appropriate when the conflict is transient — two concurrent requests that don't logically conflict but happened to race. A payment processor that increments a counter, two requests updating different fields of the same entity:
@Retryable(
retryFor = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2, random = true)
)
@Transactional
public Order updateOrderStatus(Long orderId, OrderStatus newStatus) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus(newStatus);
return orderRepository.save(order);
}
Spring Retry's @Retryable retries the method on OptimisticLockingFailureException. The retry re-reads the entity with the current version and re-applies the change. Random jitter in the backoff prevents thundering herd retry storms.
Spring Boot includes Spring Retry; add the dependency and @EnableRetry on a configuration class:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
@Configuration
@EnableRetry
public class RetryConfig {}
When not to retry. Retry is wrong when the conflict reflects a genuine business rule violation — two users attempting to claim the last available ticket, concurrent modifications to the same financial record where both changes must be visible. Retrying silently overwrites one user's intent. Surface the conflict to the user:
@Transactional
public Order claimTicket(Long eventId, Long userId) {
try {
Event event = eventRepository.findById(eventId).orElseThrow();
if (event.getAvailableTickets() <= 0) {
throw new NoTicketsAvailableException(eventId);
}
event.setAvailableTickets(event.getAvailableTickets() - 1);
Ticket ticket = ticketRepository.save(new Ticket(event, userId));
eventRepository.save(event);
return ticket;
} catch (OptimisticLockingFailureException e) {
// Another request modified the event — reload and check availability
throw new TicketClaimConflictException(
"Concurrent modification — please try again", eventId);
}
}
The caller (controller) translates TicketClaimConflictException to a 409 Conflict response. The user is informed and can retry at the UI level with current data.
Optimistic locking in REST APIs — the ETag pattern
HTTP ETags are the natural surface for optimistic locking in REST APIs. The ETag encodes the entity version; clients include it in If-Match on update requests:
GET /orders/42
→ 200 OK, ETag: "5", {status: "PENDING", total: 100, version: 5}
PATCH /orders/42
If-Match: "5"
{status: "PROCESSING"}
→ 200 OK if version is still 5
→ 412 Precondition Failed if version > 5 (concurrent modification)
Spring MVC handles If-Match with WebRequest.checkNotModified() or manually:
@PatchMapping("/{id}")
public ResponseEntity<OrderResponse> updateOrder(
@PathVariable Long id,
@RequestBody UpdateOrderRequest request,
@RequestHeader(value = "If-Match", required = false) String ifMatch) {
Order current = orderRepository.findById(id).orElseThrow();
// Validate ETag
String currentETag = "\"" + current.getVersion() + "\"";
if (ifMatch != null && !ifMatch.equals(currentETag)) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED)
.eTag(currentETag)
.build();
}
try {
current.setStatus(request.status());
Order saved = orderRepository.save(current);
return ResponseEntity.ok()
.eTag("\"" + saved.getVersion() + "\"")
.body(OrderResponse.from(saved));
} catch (OptimisticLockingFailureException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
}
Clients that don't include If-Match proceed without version checking — a choice per endpoint depending on whether concurrent modification protection is required. The ETag value in the response tells the client the new version for subsequent updates.
Pessimistic locking — the alternative
Optimistic locking detects conflicts at commit time — the work is done before the conflict is discovered. Pessimistic locking prevents conflicts by acquiring a database lock at read time — other readers or writers wait until the lock is released.
// Pessimistic write lock — blocks all other readers and writers
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Order> findById(Long id);
// Usage
Order order = orderRepository.findById(id).orElseThrow(); // acquires FOR UPDATE lock
order.setStatus(PROCESSING);
orderRepository.save(order); // lock released on commit
PESSIMISTIC_WRITE generates SELECT ... FOR UPDATE. Other transactions attempting to lock the same row wait until the first transaction commits.
When pessimistic locking is appropriate:
- High contention on a specific row where optimistic lock retries would dominate (ticket inventory, seat reservation)
- Operations that must not fail due to concurrent modification (financial transfers where retry would double-charge)
- When the cost of retry (re-executing an expensive operation) exceeds the cost of waiting
The deadlock risk. Pessimistic locks held across multiple rows can deadlock if two transactions acquire locks in different orders. Consistent lock ordering (always lock lower ID first) prevents this — the same strategy as mutex lock ordering in concurrent programming.
Timeout for pessimistic locks. Without a timeout, a transaction waiting for a pessimistic lock waits indefinitely:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
Optional<Order> findByIdForUpdate(Long id);
3000 milliseconds — the query waits up to 3 seconds for the lock. After timeout, LockTimeoutException is thrown. Handle it like OptimisticLockException — retry or surface to the caller.
Choosing between optimistic and pessimistic
Optimistic locking is the default for most entities. Contention is usually low, conflicts are rare, and the cost of an occasional retry is acceptable. The entity can be read and processed without any database overhead until commit time.
Use pessimistic locking when:
- Conflict probability is high (multiple processes race for the same resource frequently)
- The work done between read and write is expensive and retry would be prohibitive
- The business semantics require exactly-once execution (financial transfers, inventory reservation)
Use neither when:
- The entity is read-only or updated only by background jobs with no concurrent access
- The operation is idempotent and concurrent duplication is acceptable
- High-volume entities where version columns and conflict detection add measurable overhead
For most application entities — user profiles, orders, configurations — optimistic locking with retry on conflict is the correct default. The conflict rate in typical web applications is low enough that pessimistic locking's overhead is rarely justified.