How to Implement Pessimistic Locking in JPA
Pessimistic locking locks rows at the DBMS level, preventing concurrent access. Other transactions cannot read or write locked data until the current transaction completes.
Overview
Pessimistic locking locks rows at the DBMS level, preventing concurrent access. Other transactions cannot read or write locked data until the current transaction completes.
Junior Level
What is Pessimistic Locking
Pessimistic locking - locks a row in the DB at the DBMS level. Other transactions cannot read/write locked data.
// Write lock - others cannot read/write
Order order = entityManager.find(Order.class, 1L,
LockModeType.PESSIMISTIC_WRITE);
// Read lock - others can read, but not write
Order order = entityManager.find(Order.class, 1L,
LockModeType.PESSIMISTIC_READ);
// NOWAIT - don't wait, immediate error if locked
Order order = entityManager.find(Order.class, 1L,
LockModeType.PESSIMISTIC_WRITE,
Map.of("jakarta.persistence.lock.timeout", 0));
When to Use
- Critical data that is frequently updated
- When conflicts are common (optimistic locking not suitable)
- Financial operations, inventory, booking
Middle Level
LockModeType
PESSIMISTIC_READ:
- SELECT ... FOR SHARE (PostgreSQL) (PostgreSQL 9.3+; older versions used FOR UPDATE)
- Other transactions can read, but not write
PESSIMISTIC_WRITE:
- SELECT ... FOR UPDATE
- Other transactions cannot read or write
PESSIMISTIC_FORCE_INCREMENT:
- UPDATE + version increment
- Combination of pessimistic locking and optimistic versioning
Timeout
// 5 second timeout - wait max 5 seconds
entityManager.find(Order.class, id,
LockModeType.PESSIMISTIC_WRITE,
Map.of("jakarta.persistence.lock.timeout", 5000));
// NOWAIT - don't wait at all
entityManager.find(Order.class, id,
LockModeType.PESSIMISTIC_WRITE,
Map.of("jakarta.persistence.lock.timeout", 0));
Usage Example
@Transactional
public Order processOrder(Long id) {
// Write lock - other transactions wait
Order order = entityManager.find(Order.class, id,
LockModeType.PESSIMISTIC_WRITE);
// Processing - other transactions wait for completion
order.setStatus("processing");
order.setProcessedAt(LocalDateTime.now());
return order;
// On commit - lock released
}
Deadlock
Transaction 1: locks A -> waits for B
Transaction 2: locks B -> waits for A
Deadlock! -> one transaction rolled back by DB
// Handle deadlock
try {
Order order = entityManager.find(Order.class, id,
LockModeType.PESSIMISTIC_WRITE);
} catch (PessimisticLockException | LockAcquisitionException e) {
// retry or error handling
}
Common Mistakes
// Long transaction with lock
@Transactional
public void longProcess(Long id) {
Order order = entityManager.find(Order.class, id,
LockModeType.PESSIMISTIC_WRITE);
// ... long processing ...
// Other transactions wait all this time!
}
// Short transaction
@Transactional
public Order lockAndProcess(Long id) {
Order order = entityManager.find(Order.class, id,
LockModeType.PESSIMISTIC_WRITE);
order.setStatus("processing");
return order; // finish quickly
}
Senior Level
Internal Implementation
PostgreSQL: SELECT ... FOR UPDATE
MySQL: SELECT ... FOR UPDATE
Oracle: SELECT ... FOR UPDATE NOWAIT / WAIT n
SQL Server: SELECT ... WITH (UPDLOCK, ROWLOCK)
Hibernate generates correct SQL for each DBMS.
NOWAIT and SKIP LOCKED
// NOWAIT - immediate error if locked
Map<String, Object> nowait = Map.of(
"jakarta.persistence.lock.timeout", 0
);
// SKIP LOCKED - skip locked rows (Hibernate 6+)
// Useful for task queues
//
// SKIP LOCKED - skip locked rows. In JPA this is implemented via
// query hint 'jakarta.persistence.lock.scope' = EXTENDED, but full support
// depends on provider and Hibernate version. In Hibernate 6+ it works stably.
@Query("SELECT t FROM Task t WHERE t.status = 'PENDING'")
List<Task> findAvailableTasks(
@Param("jakarta.persistence.lock.scope") LockScope EXTENDED,
@Param("jakarta.persistence.lock.timeout", 0)
);
Query-Level Locking
// Lock in JPQL query
@Query("SELECT o FROM Order o WHERE o.status = :status")
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Order> findOrdersForUpdate(@Param("status") String status);
// Lock in native query
@Query(value = "SELECT * FROM orders WHERE id = :id FOR UPDATE",
nativeQuery = true)
Order findByIdForUpdate(@Param("id") Long id);
Production Patterns
// Pattern 1: Booking with lock
@Transactional
public boolean reserveItem(Long itemId, int quantity) {
Item item = entityManager.find(Item.class, itemId,
LockModeType.PESSIMISTIC_WRITE);
if (item.getStock() >= quantity) {
item.setStock(item.getStock() - quantity);
return true;
}
return false;
}
// Pattern 2: Queue processing with SKIP LOCKED
@QueryHints({
@QueryHint(name = "jakarta.persistence.lock.timeout", value = "0"),
@QueryHint(name = "jakarta.persistence.lock.scope", value = "EXTENDED")
})
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT t FROM Task t WHERE t.status = 'PENDING' ORDER BY t.createdAt")
List<Task> findNextAvailableTask();
Optimization
Short transactions with lock
Timeout to prevent long waits
Indexes for locked columns
Retry on deadlock
SKIP LOCKED for queues
Long transactions with lock
Without timeout
Without indexing locked columns
Locking without necessity
Ignoring PessimisticLockException
Monitoring
// Monitor locks in PostgreSQL
@Query(value = """
SELECT blocked_locks.pid AS blocked_pid,
blocking_locks.pid AS blocking_pid,
blocked_activity.query AS blocked_statement
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
WHERE NOT blocked_locks.granted
""", nativeQuery = true)
List<Object[]> findBlockedQueries();
Best Practices
Short transactions
Lock timeout
Indexes for locked columns
Retry on deadlock
SKIP LOCKED for queues
PESSIMISTIC_WRITE for critical data
Long transactions with lock
Without timeout
Without indexing
Locking for read-only operations
Ignoring LockAcquisitionException
Interview Cheat Sheet
Must know:
- Pessimistic locking - SELECT … FOR UPDATE at DBMS level
- LockModeType: PESSIMISTIC_READ (FOR SHARE), PESSIMISTIC_WRITE (FOR UPDATE)
- Timeout: jakarta.persistence.lock.timeout (0 = NOWAIT, otherwise milliseconds)
- Deadlock is possible - one transaction will be rolled back by DB
- SKIP LOCKED (Hibernate 6+) - skip locked rows, useful for queues
- Short transactions - other transactions wait for lock completion
Frequent follow-up questions:
- When is pessimistic better than optimistic? Frequent conflicts, critical data, financial operations
- What is SKIP LOCKED? Skips locked rows - ideal for task queues
- READ vs WRITE lock? READ - others can read but not write; WRITE - others cannot read/write
- How to handle deadlock? Retry logic, monitor blocked queries, indexes for locked columns
Red flags (DO NOT say):
- “Long transaction with lock” - other transactions wait the entire time
- “Without timeout for lock” - infinite waiting
- “Pessimistic for read-only” - unnecessary locking
- “I ignore LockAcquisitionException” - deadlock not handled
Related topics:
- [[17. How to Implement Optimistic Locking in JPA]]
- [[19. What is @Version and Why is It Needed]]
- [[15. What Does the refresh() Method Do]]
- [[13. How Does the Flush Mechanism Work in Hibernate]]