Home Java Spring Transaction Locks: Optimistic vs Pessimistic Locking
Advanced 4 min · July 14, 2026

Spring Transaction Locks: Optimistic vs Pessimistic Locking

Master transaction locking in Spring Data JPA.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic knowledge of Spring Boot and Spring Data JPA
  • Understanding of database transactions and ACID properties
  • Familiarity with Java annotations and dependency injection
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Use optimistic locking for read-heavy, low-conflict systems; it's non-blocking and uses version fields.
  • Use pessimistic locking for write-heavy, high-conflict scenarios; it prevents concurrent modifications with database locks.
  • Always define a clear retry strategy for optimistic locking failures to avoid silent data loss.
  • Pessimistic locks can cause deadlocks; keep lock duration minimal and order lock acquisitions consistently.
  • Test locking behavior under realistic concurrency; integration tests with multiple threads are mandatory.
✦ Definition~90s read
What is Enabling Transaction Locks in Spring Data JPA?

Transaction locking in Spring Data JPA controls how concurrent transactions access the same data, using optimistic (version-based) or pessimistic (database lock) strategies to maintain consistency.

Imagine two people editing the same document.
Plain-English First

Imagine two people editing the same document. Optimistic locking is like each person taking a copy, editing, and then checking if the other changed it before saving—if so, they redo their work. Pessimistic locking is like locking the document so only one person can edit at a time, preventing conflicts but slowing things down.

If you've ever dealt with concurrent transactions in a Spring Boot application, you've probably encountered the dreaded OptimisticLockException or a deadlock that brings your database to its knees. Locking is not just an academic concept—it's a critical tool for maintaining data integrity under concurrent access. I've seen production outages caused by a missing @Version annotation and a deadlock cascade that took down a payment processing system for 15 minutes. In this article, I'll walk you through the two primary locking strategies in Spring Data JPA: optimistic and pessimistic locking. You'll learn when to use each, how to implement them correctly, and—more importantly—what the official documentation glosses over. By the end, you'll have a battle-tested approach to handling concurrent data access that won't leave you debugging at 2 AM.

What Is Transaction Locking in JPA?

Transaction locking is the mechanism that prevents concurrent transactions from interfering with each other when accessing the same data. In JPA, you have two main strategies: optimistic and pessimistic locking. Optimistic locking assumes conflicts are rare; it checks at commit time whether the data has changed since it was read. Pessimistic locking assumes conflicts are frequent; it locks the data when it's read, preventing others from modifying it until the lock is released.

Spring Data JPA provides annotations like @Version for optimistic locking and @Lock for pessimistic locking. You can also use LockModeType enums in repository methods. The choice between them depends on your application's concurrency profile. I've seen teams default to pessimistic locking because it 'feels safer,' only to run into deadlocks and performance bottlenecks. The truth is, optimistic locking is often the better choice for most web applications because it scales better and avoids long-held locks.

OptimisticLockingExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Entity
public class Inventory {
    @Id
    private Long id;
    private int quantity;
    @Version
    private Long version;
    // getters and setters
}

// Repository
public interface InventoryRepository extends JpaRepository<Inventory, Long> {}

// Service
@Service
public class InventoryService {
    @Transactional
    public void decrementStock(Long productId, int amount) {
        Inventory inv = inventoryRepository.findById(productId).orElseThrow();
        if (inv.getQuantity() < amount) throw new InsufficientStockException();
        inv.setQuantity(inv.getQuantity() - amount);
        // version is automatically incremented; if stale, OptimisticLockException thrown
    }
}
🔥Key Insight
📊 Production Insight
Always include @Version in entities that are updated frequently. I've seen a production bug where the version field was accidentally removed during a refactor, causing silent data loss for hours.
🎯 Key Takeaway
Optimistic locking is a non-blocking strategy that works well in read-heavy systems with low contention.

Pessimistic Locking in Spring Data JPA

Pessimistic locking acquires a database lock on the selected rows, preventing other transactions from modifying or sometimes even reading them until the lock is released. In Spring Data JPA, you can use the @Lock annotation on repository methods to specify the lock mode: PESSIMISTIC_READ (shared lock) or PESSIMISTIC_WRITE (exclusive lock).

Here's the hard truth: pessimistic locking can be a performance killer if misused. Every PESSIMISTIC_WRITE lock blocks other write transactions and, depending on your database and isolation level, even read transactions. In MySQL with InnoDB, PESSIMISTIC_WRITE uses SELECT ... FOR UPDATE, which locks the rows until the transaction commits or rolls back. If your transaction takes a long time—say, because of a slow external API call—you'll have a queue of blocked transactions piling up.

I once debugged a system where a developer wrapped an entire REST endpoint in a @Transactional with PESSIMISTIC_WRITE. The endpoint made an HTTP call to a third-party service that sometimes timed out after 30 seconds. The result? A deadlock cascade that took down the application. The fix was to move the lock acquisition as late as possible and keep the transaction scope minimal.

PessimisticLockingExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface AccountRepository extends JpaRepository<Account, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id = :id")
    Optional<Account> findByIdWithLock(@Param("id") Long id);
}

@Service
public class TransferService {
    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findByIdWithLock(fromId).orElseThrow();
        Account to = accountRepository.findByIdWithLock(toId).orElseThrow();
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        // both accounts locked until transaction commits
    }
}
⚠ Deadlock Warning
📊 Production Insight
Set a lock timeout in your database to prevent indefinite waits. In PostgreSQL, use lock_timeout; in MySQL, use innodb_lock_wait_timeout. Defaults are often too high (50 seconds in MySQL).
🎯 Key Takeaway
Pessimistic locking is for high-contention scenarios but must be used with caution to avoid deadlocks and performance degradation.

Choosing Between Optimistic and Pessimistic Locking

  • Optimistic locking is the default choice. Use it when contention is low (i.e., conflicts are rare) and you can afford to retry failed transactions. Most web applications fall into this category: many reads, few writes, and conflicts are exceptions.
  • Pessimistic locking is for high-contention scenarios where the cost of retries is unacceptable. For example, a seat reservation system where two users might try to book the same seat simultaneously. In such cases, it's better to lock the seat immediately and let the second user wait or see it as unavailable.

But there's a middle ground: you can combine both. Use optimistic locking as the primary strategy, but fall back to pessimistic locking for specific high-contention operations. Spring Data JPA allows you to specify lock modes on a per-query basis, so you can have a default optimistic approach and override for critical paths.

I've also seen teams use database-specific hints to fine-tune lock behavior. For example, PostgreSQL's SKIP LOCKED is useful for queue-like workloads where you want to skip locked rows instead of waiting. Spring Data JPA doesn't support this natively, but you can use native queries or Hibernate's @QueryHints.

CombinedLockingStrategy.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Default optimistic locking via @Version
@Entity
public class Seat {
    @Id private Long id;
    @Version private Long version;
    private boolean reserved;
}

// Pessimistic lock for booking
public interface SeatRepository extends JpaRepository<Seat, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Seat s where s.id = :id")
    Optional<Seat> findByIdForBooking(@Param("id") Long id);
}

@Service
public class BookingService {
    @Transactional
    public void bookSeat(Long seatId) {
        Seat seat = seatRepository.findByIdForBooking(seatId).orElseThrow();
        if (seat.isReserved()) throw new SeatAlreadyReservedException();
        seat.setReserved(true);
    }
}
💡Hybrid Approach
📊 Production Insight
Measure lock contention in production using database monitoring tools. In MySQL, SHOW ENGINE INNODB STATUS gives you lock waits. In PostgreSQL, pg_locks and pg_stat_activity are your friends.
🎯 Key Takeaway
Start with optimistic locking; only introduce pessimistic locking when you have measured high contention and retries are too costly.

What the Official Docs Won't Tell You

I've spent years debugging locking issues, and here are the gotchas that the Spring Data JPA documentation doesn't emphasize enough:

  1. Optimistic locking works only if you use @Version correctly. @Version must be on a numeric, Timestamp, or Instant field. If you put it on a String, it won't work. Also, if you update an entity without reading it first (e.g., using a custom @Modifying query), the version check is bypassed. You must manually include version in the WHERE clause.
  2. Pessimistic locking in Spring Data JPA is not always honored. If you call a repository method with @Lock(PESSIMISTIC_WRITE) but the method is not inside a transaction, no lock is acquired. The lock is only active during the transaction. Also, if you use findById with @Lock, Hibernate might ignore the lock hint because it uses the persistence context first. Always use @Query to force a database query.
  3. Lock escalation can happen. In some databases, row-level locks can escalate to table-level locks if too many rows are locked, causing unexpected contention. Monitor your lock granularity.
  4. Timeouts are critical. Both optimistic and pessimistic locking can lead to long waits if not configured properly. Set transaction timeouts with @Transactional(timeout = 5) (seconds) to avoid hanging threads.
  5. Distributed transactions complicate locking. If you use multiple databases or a message broker, JPA locks won't protect you across systems. Consider distributed locking with Redis or ZooKeeper for cross-service consistency.
VersionCheckBypass.javaJAVA
1
2
3
4
5
6
7
8
9
// BAD: version not checked
@Modifying
@Query("update Inventory i set i.quantity = i.quantity - :amount where i.id = :id")
int decrementStock(@Param("id") Long id, @Param("amount") int amount);

// GOOD: include version check
@Modifying
@Query("update Inventory i set i.quantity = i.quantity - :amount, i.version = i.version + 1 where i.id = :id and i.version = :version")
int decrementStockWithVersionCheck(@Param("id") Long id, @Param("amount") int amount, @Param("version") Long version);
⚠ Common Pitfall
📊 Production Insight
Always test locking behavior under load with concurrent threads. I use a simple JUnit test with ExecutorService to simulate race conditions. It's saved me from many production bugs.
🎯 Key Takeaway
Optimistic locking is only as good as your version field management. Pessimistic locking requires careful transaction design and timeout configuration.

Implementing Retry Logic for Optimistic Locking

Optimistic locking fails with OptimisticLockException when a concurrent update is detected. Without a retry mechanism, the user gets an error and the operation fails. In a typical web application, you should automatically retry the transaction a few times. Spring Retry provides a declarative way to do this.

Add spring-retry and spring-boot-starter-aop to your dependencies. Then annotate your service method with @Retryable. You can configure backoff policies to avoid hammering the database.

I've seen teams set retries to 3 with an exponential backoff (e.g., 1 second, 2 seconds, 4 seconds). But don't set it too high—if contention is persistent, retrying 10 times will just waste resources. In that case, switch to pessimistic locking for that operation.

One more thing: make sure your retry logic is idempotent. If the operation has side effects (e.g., sending an email), you might duplicate them. Use the @Retryable annotation on the transactional method, but ensure that any non-idempotent actions are either moved outside the transaction or compensated.

RetryLogicExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class InventoryService {
    @Retryable(value = OptimisticLockException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
    @Transactional
    public void decrementStock(Long productId, int amount) {
        Inventory inv = inventoryRepository.findById(productId).orElseThrow();
        if (inv.getQuantity() < amount) throw new InsufficientStockException();
        inv.setQuantity(inv.getQuantity() - amount);
    }

    @Recover
    public void recover(OptimisticLockException e, Long productId, int amount) {
        log.error("Failed to decrement stock for product {} after retries", productId, e);
        // Optionally notify admin or move to a dead-letter queue
    }
}
💡Retry with Caution
📊 Production Insight
Monitor the number of retries in production. A sudden increase in retries can indicate a contention problem that might require switching to pessimistic locking.
🎯 Key Takeaway
Always implement retry logic for optimistic locking. Use Spring Retry with exponential backoff and a recovery fallback.

Testing Locking Behavior

Testing concurrent behavior is tricky but essential. Unit tests won't catch race conditions; you need integration tests that simulate multiple threads accessing the same data. I use ExecutorService to launch several threads that concurrently invoke the same service method. Then I assert the final state of the database.

Spring Boot's @SpringBootTest with an embedded database works well for this. Use CountDownLatch to synchronize thread start times. Also, set the database isolation level to READ_COMMITTED (default) to match production.

One gotcha: embedded databases like H2 have different locking behaviors than MySQL or PostgreSQL. Always run your concurrency tests against the same database you use in production. I've seen tests pass on H2 but fail on PostgreSQL because of different lock implementations.

ConcurrencyTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@SpringBootTest
class InventoryServiceConcurrencyTest {
    @Autowired private InventoryService inventoryService;
    @Autowired private InventoryRepository inventoryRepository;

    @Test
    void testConcurrentDecrement() throws InterruptedException {
        Inventory inv = new Inventory();
        inv.setQuantity(10);
        inv = inventoryRepository.save(inv);
        Long id = inv.getId();

        int threadCount = 5;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(1);
        List<Future<?>> futures = new ArrayList<>();

        for (int i = 0; i < threadCount; i++) {
            futures.add(executor.submit(() -> {
                try {
                    latch.await();
                    inventoryService.decrementStock(id, 1);
                } catch (Exception e) {
                    // expected if optimistic lock fails
                }
            }));
        }

        latch.countDown(); // start all threads simultaneously
        for (Future<?> f : futures) f.get(); // wait for all to finish

        Inventory updated = inventoryRepository.findById(id).orElseThrow();
        // With optimistic locking, only 1 decrement should succeed (since retries are disabled for test)
        assertThat(updated.getQuantity()).isLessThan(10);
    }
}
🔥Test Against Real Database
📊 Production Insight
Add concurrency tests to your CI pipeline. They catch regressions when someone accidentally removes a @Version or changes transaction boundaries.
🎯 Key Takeaway
Concurrency testing is non-negotiable. Use Testcontainers and simulate multiple threads to verify locking works as expected.
● Production incidentPOST-MORTEMseverity: high

The Case of the Vanishing Inventory

Symptom
Customers could add items to cart, but checkout occasionally failed with 'Insufficient stock' even though inventory showed positive. Some orders succeeded with partial stock.
Assumption
The developer assumed that checking stock quantity in a synchronized block on a single server was sufficient.
Root cause
Under high concurrency, two threads read the same stock count (e.g., 1), both decremented it, and wrote back. The second write overwrote the first, causing the final stock to be 0 instead of -1 (or negative). The synchronized block only worked within one instance; with multiple instances, it was useless.
Fix
Added @Version column to the Inventory entity and enabled optimistic locking. Also implemented a retry mechanism with Spring Retry for OptimisticLockException.
Key lesson
  • Always use @Version for entities that are updated concurrently.
  • Never rely on application-level locking alone in distributed systems.
  • Implement retry logic for optimistic lock failures.
  • Monitor lock contention metrics in production.
  • Use pessimistic locking only when conflicts are frequent and retries are too costly.
Production debug guideSymptom to Action4 entries
Symptom · 01
OptimisticLockException in logs
Fix
Check if the entity has a @Version field. If not, add one. If yes, increase retry count or switch to pessimistic locking if contention is high.
Symptom · 02
Deadlock detected by database
Fix
Review transaction boundaries and lock acquisition order. Ensure all transactions acquire locks in the same order. Reduce lock scope (e.g., use PESSIMISTIC_READ instead of PESSIMISTIC_WRITE if possible).
Symptom · 03
High response times under load
Fix
Check for pessimistic lock waits. Use database monitoring to find long-running transactions. Consider switching to optimistic locking or reducing lock timeout.
Symptom · 04
Lost updates (data inconsistency)
Fix
Verify that all update operations are within transactions and that the entity version is checked. Enable Hibernate SQL logging to see if version is included in UPDATE statements.
★ Quick Debug Cheat SheetImmediate actions for common locking issues
OptimisticLockException
Immediate action
Add retry logic with Spring Retry
Commands
@Retryable(value = OptimisticLockException.class, maxAttempts = 3)
Check @Version annotation presence
Fix now
Increase maxAttempts or switch to pessimistic locking
Deadlock+
Immediate action
Identify deadlock graph from DB logs
Commands
SHOW ENGINE INNODB STATUS
Ensure consistent lock ordering
Fix now
Refactor transaction to acquire locks in the same order
Slow queries with lock wait+
Immediate action
Monitor lock wait time
Commands
SELECT * FROM information_schema.INNODB_TRX
Check for long-running transactions
Fix now
Reduce transaction scope or use optimistic locking
FeatureOptimistic LockingPessimistic Locking
MechanismVersion field checkDatabase lock (SELECT ... FOR UPDATE)
BlockingNon-blocking (retry on failure)Blocking (waits for lock)
PerformanceHigh (no lock overhead)Lower (lock acquisition overhead)
ContentionBest for low contentionBest for high contention
Deadlock riskNonePossible if not careful
Spring annotation@Version@Lock(LockModeType.PESSIMISTIC_WRITE)
Retry neededYesNo (but timeout needed)
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
OptimisticLockingExample.java@EntityWhat Is Transaction Locking in JPA?
PessimisticLockingExample.javapublic interface AccountRepository extends JpaRepository {Pessimistic Locking in Spring Data JPA
CombinedLockingStrategy.java@EntityChoosing Between Optimistic and Pessimistic Locking
VersionCheckBypass.java@ModifyingWhat the Official Docs Won't Tell You
RetryLogicExample.java@ServiceImplementing Retry Logic for Optimistic Locking
ConcurrencyTest.java@SpringBootTestTesting Locking Behavior

Key takeaways

1
Optimistic locking with @Version is the default and should be used for most scenarios; it's non-blocking and scales well.
2
Pessimistic locking is powerful but must be used with caution to avoid deadlocks and performance issues.
3
Always implement retry logic for optimistic locking failures using Spring Retry with exponential backoff.
4
Test locking behavior under realistic concurrency using Testcontainers and multithreaded tests.
5
Monitor lock contention in production using database tools to detect issues early.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain optimistic locking in JPA. How does it work under the hood?
Q02SENIOR
When would you choose pessimistic locking over optimistic locking?
Q03SENIOR
How would you implement a retry mechanism for optimistic locking failure...
Q01 of 03SENIOR

Explain optimistic locking in JPA. How does it work under the hood?

ANSWER
Optimistic locking uses a version field annotated with @Version. When an entity is read, the version is captured. On update, JPA includes a WHERE clause like WHERE id = ? AND version = ?. If the version has changed, the update affects zero rows and Hibernate throws an OptimisticLockException. It's a lightweight mechanism that avoids database locks.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between optimistic and pessimistic locking?
02
How do I enable optimistic locking in Spring Data JPA?
03
Can I use pessimistic locking with Spring Data JPA?
04
What happens if an OptimisticLockException is thrown?
05
How do I prevent deadlocks with pessimistic locking?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Hibernate & JPA. Mark it forged?

4 min read · try the examples if you haven't

Previous
Using Read-Only Transactions in Spring: Performance and Optimization
15 / 28 · Hibernate & JPA
Next
CrudRepository, JpaRepository, and PagingAndSortingRepository in Spring Data: Choosing the Right Repository