Two requests arrive at your endpoint at the same moment. Both read the same row. Both compute an updated value. Both write it back. One write silently overwrites the other, and no exception is ever thrown. This is the lost update problem, and it is one of the most common concurrency bugs in database-backed applications.
JPA gives you two tools to prevent it: optimistic locking and pessimistic locking. This tutorial walks through both with working code and real SQL output so you can see exactly what each approach does at the database level.
All code is in the companion repo: github.com/umur/jpa-locking. Run mvn verify and all 5 tests pass.
The Lost Update Problem
The service method that causes trouble looks completely harmless:
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId).orElseThrow();
product.setStock(product.getStock() - quantity);
}The problem is what happens when two transactions run this concurrently:
-- Transaction A reads stock = 10
SELECT id, name, stock FROM product WHERE id = 1;
-- Transaction B reads stock = 10 (before A commits)
SELECT id, name, stock FROM product WHERE id = 1;
-- Transaction A decreases by 3, commits stock = 7
UPDATE product SET stock = 7 WHERE id = 1;
-- Transaction B also decreases by 3 from its stale read, commits stock = 7
UPDATE product SET stock = 7 WHERE id = 1;
-- Final stock: 7 instead of the correct 4No error is thrown. No exception is logged. The database accepted both updates happily. Transaction A's write was simply overwritten.
Two fixes exist: detect the conflict at commit time (optimistic), or prevent concurrent reads from happening at all (pessimistic).
The Setup
Both strategies use the same Product entity. What differs: the presence of a @Version field and which repository method you call.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Getter
@Setter
@NoArgsConstructor
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int stock;
@Version
private int version;
public Product(String name, int stock) {
this.name = name;
this.stock = stock;
}
}Optimistic locking hinges on the @Version field. Hibernate manages it entirely. You never read or write it in your own code.
Optimistic Locking
Optimistic locking assumes conflicts are rare. Transactions proceed without holding any database lock. At commit time, Hibernate checks whether the row was modified since it was read. If it was, the commit fails with an exception instead of silently overwriting the earlier change.
The mechanism is simple. On every UPDATE, Hibernate appends a WHERE version = ? clause and increments the value:
-- READ: version is included in the SELECT
SELECT id, name, stock, version FROM product WHERE id = 1;
-- result: stock = 10, version = 0
-- WRITE: version is checked and incremented atomically
UPDATE product
SET stock = 7, version = 1
WHERE id = 1 AND version = 0;
-- If 0 rows were updated: someone else already incremented version.
-- Hibernate throws OptimisticLockException.If the WHERE version = 0 clause matches 0 rows because another transaction already incremented to 1, Hibernate throws OptimisticLockException. Spring wraps this as ObjectOptimisticLockingFailureException. No silent overwrite ever happens.
The test confirms it by simulating two concurrent transactions with a shared product:
import org.junit.jupiter.api.Test;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.transaction.support.TransactionTemplate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Test
@DisplayName("concurrent modification throws ObjectOptimisticLockingFailureException")
void concurrentModificationThrowsOptimisticLockException() {
Product product = productRepository.save(new Product("Widget", 10));
// Thread A loads the entity at version 0
Product staleProduct = productRepository.findById(product.getId()).orElseThrow();
// Thread B commits first: stock becomes 7, version becomes 1
transactionTemplate.execute(status -> {
Product fresh = productRepository.findById(product.getId()).orElseThrow();
fresh.setStock(fresh.getStock() - 3);
productRepository.save(fresh);
return null;
});
// Thread A tries to save its stale copy (version = 0, but DB is now at version = 1)
staleProduct.setStock(staleProduct.getStock() - 3);
assertThatThrownBy(() ->
transactionTemplate.execute(status -> {
productRepository.saveAndFlush(staleProduct);
return null;
})
).isInstanceOf(ObjectOptimisticLockingFailureException.class);
// Thread B's write is preserved
Product result = productRepository.findById(product.getId()).orElseThrow();
assertThat(result.getStock()).isEqualTo(7);
assertThat(result.getVersion()).isEqualTo(1);
}Handling the Exception with Retry
When you catch ObjectOptimisticLockingFailureException, the standard response is to retry the operation. The retry reads a fresh copy of the row, so it either succeeds or detects a new conflict.
Spring Retry handles this cleanly with @Retryable:
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class OptimisticInventoryService {
private final ProductRepository productRepository;
@Retryable(
retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 50)
)
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId).orElseThrow();
if (product.getStock() < quantity) {
throw new IllegalStateException("Insufficient stock");
}
product.setStock(product.getStock() - quantity);
}
}This requires spring-retry on the classpath and @EnableRetry on your application class. If the operation still fails after 3 attempts, the exception propagates to the caller. Design your API to handle that case, a 409 Conflict response for example.
Pessimistic Locking
Pessimistic locking assumes conflicts are likely. A database-level lock is acquired the moment a row is read. Any other transaction that tries to read the same row for update blocks until the first transaction commits or rolls back. No conflict is ever possible because concurrent reads for update are serialized by the database.
Add a repository method with @Lock:
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdWithLock(@Param("id") Long id);
}Hibernate generates a locking SELECT. On PostgreSQL, PESSIMISTIC_WRITE uses FOR NO KEY UPDATE, not FOR UPDATE. It is functionally equivalent for this use case but avoids blocking foreign key lookups, which makes it less likely to cause unrelated lock waits.
-- Transaction A acquires the lock (PostgreSQL uses FOR NO KEY UPDATE)
SELECT id, name, stock FROM product WHERE id = 1 FOR NO KEY UPDATE;
-- Transaction B tries to acquire the same lock, blocks here until A commits
SELECT id, name, stock FROM product WHERE id = 1 FOR NO KEY UPDATE; -- waiting
-- Transaction A commits: stock = 7
UPDATE product SET stock = 7 WHERE id = 1;
-- Transaction B unblocks and reads the fresh value (stock = 7)
-- It decreases by 3 and commits: stock = 4
UPDATE product SET stock = 4 WHERE id = 1;
-- Final stock: 4 (correct)The service method uses the locking repository method instead of the standard one:
@Service
@RequiredArgsConstructor
public class PessimisticInventoryService {
private final ProductRepository productRepository;
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findByIdWithLock(productId).orElseThrow();
if (product.getStock() < quantity) {
throw new IllegalStateException("Insufficient stock");
}
product.setStock(product.getStock() - quantity);
}
}The concurrent test proves it always produces the correct result:
@Test
@DisplayName("concurrent access with pessimistic locking always produces correct stock")
void concurrentAccessProducesCorrectStock() throws InterruptedException {
Product product = productRepository.save(new Product("Widget", 10));
Long productId = product.getId();
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(2);
List<Exception> errors = new CopyOnWriteArrayList<>();
Runnable task = () -> {
try {
start.await();
inventoryService.decreaseStock(productId, 3);
} catch (Exception e) {
errors.add(e);
} finally {
done.countDown();
}
};
new Thread(task).start();
new Thread(task).start();
start.countDown();
done.await(10, TimeUnit.SECONDS);
assertThat(errors).isEmpty();
Product result = productRepository.findById(productId).orElseThrow();
assertThat(result.getStock()).isEqualTo(4); // 10 - 3 - 3, always correct
}Deadlock Risk
Pessimistic locking introduces deadlock risk when two transactions lock rows in different orders. Transaction A locks product 1 then waits for product 2. Transaction B locks product 2 then waits for product 1. Both wait forever.
-- Transaction A
SELECT * FROM product WHERE id = 1 FOR UPDATE; -- A acquires lock on 1
SELECT * FROM product WHERE id = 2 FOR UPDATE; -- A waits for B to release 2
-- Transaction B
SELECT * FROM product WHERE id = 2 FOR UPDATE; -- B acquires lock on 2
SELECT * FROM product WHERE id = 1 FOR UPDATE; -- B waits for A to release 1
-- Deadlock. The database kills one transaction.The fix is to always acquire locks in a consistent order, lowest ID first for example. If your code locks multiple rows, sort them by ID before acquiring any locks.
PESSIMISTIC_READ vs PESSIMISTIC_WRITE
JPA offers two pessimistic lock modes:
PESSIMISTIC_WRITE: exclusive lock (SELECT FOR UPDATE). No other transaction can read or write the row until this one commits. Use this when you intend to update.PESSIMISTIC_READ: shared lock (SELECT FOR SHAREin PostgreSQL). Multiple transactions can hold a shared lock simultaneously, but none can acquire a write lock. Use this when you need to read a consistent value that must not change but do not intend to update it yourself.
In practice, PESSIMISTIC_WRITE is what you need for stock, balance, and seat reservation scenarios where a read is always followed by a write.
Quick Reference
| Property | Optimistic | Pessimistic |
|---|---|---|
| Database lock held? | No | Yes |
| Conflict detection | At commit time | At read time (blocks) |
| On conflict | Exception is thrown | Second transaction waits |
| Throughput | High (no blocking) | Lower (serializes access) |
| Deadlock risk | None | Yes (multi-row scenarios) |
| Best fit | Low contention, short transactions | High contention, must not retry |
| Setup | @Version field on entity | @Lock on repository method |
The Rule of Thumb
Start with optimistic locking. Add @Version to every entity that gets updated concurrently. Handle ObjectOptimisticLockingFailureException with a retry. This is the right default for most web applications because conflict rates are low and throughput is preserved.
Switch to pessimistic locking when conflicts are frequent enough that retries dominate latency, or when the operation cannot be retried at all. In financial systems where multiple users reliably compete for the same row, pessimistic locking gives you clean serialization without the retry overhead.
There is also a third option worth knowing: a single atomic SQL statement. UPDATE product SET stock = stock - 3 WHERE id = 1 AND stock >= 3 is by design concurrent-safe and needs no JPA-level locking. If it updates 0 rows, stock was insufficient or already modified. Check the affected row count and handle it in application code. When the operation fits in one statement, this is the simplest approach of all.
The tests in the companion repo cover all scenarios. Clone it, run mvn verify, and each test name describes exactly what behavior it asserts.