Full Stack • Java • System Design • Cloud • AI Engineering

Optimistic vs Pessimistic Locking in JPA and Hibernate

Complete guide to optimistic and pessimistic locking with concurrency problems, lost updates, @Version, LockModeType, Spring Data JPA examples, diagrams, SQL behavior, best practices, and interview questions.

What is Locking?

Locking is a technique used to handle concurrent access to the same database record.

Simple meaning:

Multiple users try to update the same row at the same time.
Locking decides who can update safely.

Why Locking is Needed

Imagine two users editing the same bank account balance.

Current Balance = 1000

User A:

Withdraw 100

User B:

Withdraw 200

If both read the same old balance and update independently, final balance can become wrong.

This problem is called:

Lost Update Problem

Lost Update Problem

flowchart TD

A["Balance = 1000"]

B["User A Reads 1000"]

C["User B Reads 1000"]

D["User A Updates Balance 900"]

E["User B Updates Balance 800"]

F["Final Balance 800"]

G["User A Update Lost"]

A --> B
A --> C
B --> D
C --> E
D --> F
E --> F
F --> G

Correct final balance should be:

700

But actual final balance becomes:

800

Because User B overwrites User A.


Two Main Locking Types

Optimistic Locking
Pessimistic Locking

High Level Difference

Feature Optimistic Locking Pessimistic Locking
Assumption Conflict is rare Conflict is likely
DB Lock No lock while reading Row is locked
Performance Better Slower
Concurrency High Lower
Failure Fails during commit Waits or blocks
Best For Read-heavy systems Critical updates

Real Life Analogy

Optimistic Locking

Google Docs version history

Many people can open the document.
System checks version before saving.
If version changed, conflict is detected.

Pessimistic Locking

Library book checkout

One person takes the book.
Others must wait until the book is returned.

Sample Entity

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import jakarta.persistence.Table;

@Entity
@Table(name = "bank_accounts")
public class BankAccount {

    @Id
    private Long id;

    private String accountNumber;

    private Double balance;

    @Version
    private Integer version;

    public BankAccount() {
    }

    public BankAccount(Long id, String accountNumber, Double balance) {
        this.id = id;
        this.accountNumber = accountNumber;
        this.balance = balance;
    }

    // getters and setters
}

Optimistic Locking

What is Optimistic Locking?

Optimistic locking assumes:

Multiple users may read same data,
but update conflict is rare.

Hibernate does not lock the database row while reading.

Instead, it checks the entity version during update.


How Optimistic Locking Works

When entity is loaded:

id = 1
balance = 1000
version = 1

User A loads version 1.

User B also loads version 1.

User A updates first:

UPDATE bank_accounts
SET balance = 900,
    version = 2
WHERE id = 1
AND version = 1;

Success.

User B updates later:

UPDATE bank_accounts
SET balance = 800,
    version = 2
WHERE id = 1
AND version = 1;

Fails.

Because database version is already 2.

Hibernate throws:

OptimisticLockException

Optimistic Locking Diagram

sequenceDiagram
    participant A as User A
    participant B as User B
    participant DB as Database

    A->>DB: Read account version 1
    B->>DB: Read account version 1

    A->>DB: Update where version 1
    DB-->>A: Success version becomes 2

    B->>DB: Update where version 1
    DB-->>B: Failed version mismatch

Optimistic Locking Code

@Service
public class AccountService {

    private final BankAccountRepository bankAccountRepository;

    public AccountService(BankAccountRepository bankAccountRepository) {
        this.bankAccountRepository = bankAccountRepository;
    }

    @Transactional
    public void withdraw(Long accountId, double amount) {

        BankAccount account =
                bankAccountRepository.findById(accountId)
                        .orElseThrow();

        double newBalance = account.getBalance() - amount;

        account.setBalance(newBalance);

        // No save required if entity is managed.
        // Dirty checking + @Version will update safely.
    }
}

Generated SQL

SELECT *
FROM bank_accounts
WHERE id = 1;

During commit:

UPDATE bank_accounts
SET balance = ?,
    version = ?
WHERE id = ?
AND version = ?;

Spring Data Repository

import org.springframework.data.jpa.repository.JpaRepository;

public interface BankAccountRepository
        extends JpaRepository<BankAccount, Long> {
}

No special method needed for basic optimistic locking.

Only add:

@Version
private Integer version;

Handling OptimisticLockException

import jakarta.persistence.OptimisticLockException;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class SafeAccountService {

    private final BankAccountRepository repository;

    public SafeAccountService(BankAccountRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void withdraw(Long accountId, double amount) {

        try {
            BankAccount account =
                    repository.findById(accountId).orElseThrow();

            account.setBalance(account.getBalance() - amount);

        } catch (ObjectOptimisticLockingFailureException ex) {
            throw new RuntimeException(
                    "Account was updated by another transaction. Please retry.",
                    ex
            );
        }
    }
}

Important:

In real applications, retry should usually happen outside the failed transaction.

Retry Example

@Service
public class AccountRetryService {

    private final AccountService accountService;

    public AccountRetryService(AccountService accountService) {
        this.accountService = accountService;
    }

    public void withdrawWithRetry(Long accountId, double amount) {

        int maxAttempts = 3;

        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                accountService.withdraw(accountId, amount);
                return;
            } catch (ObjectOptimisticLockingFailureException ex) {
                if (attempt == maxAttempts) {
                    throw ex;
                }
            }
        }
    }
}

Optimistic Locking Good Use Cases

✅ Product details update

✅ Customer profile update

✅ CMS/blog post editing

✅ Employee master data

✅ Read-heavy applications

✅ Low conflict systems


Optimistic Locking Bad Use Cases

❌ High-frequency account balance updates

❌ Flash sale inventory decrement

❌ Seat booking final confirmation

❌ Critical financial ledger updates

For these, pessimistic locking may be better.


Pessimistic Locking

What is Pessimistic Locking?

Pessimistic locking assumes:

Conflict is likely.
So lock the row before updating.

When one transaction locks a row, other transactions must wait.


Pessimistic Locking Diagram

sequenceDiagram
    participant A as Transaction A
    participant B as Transaction B
    participant DB as Database

    A->>DB: Select account for update
    DB-->>A: Row locked

    B->>DB: Select same account for update
    DB-->>B: Wait

    A->>DB: Update and commit
    DB-->>A: Lock released

    DB-->>B: Row available
    B->>DB: Continue update

LockModeType Options

Lock Mode Meaning
PESSIMISTIC_READ Shared read lock
PESSIMISTIC_WRITE Exclusive write lock
PESSIMISTIC_FORCE_INCREMENT Write lock and increment version
OPTIMISTIC Optimistic version check
OPTIMISTIC_FORCE_INCREMENT Optimistic check and increment version

Pessimistic Write Lock Example

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 java.util.Optional;

public interface BankAccountRepository
        extends JpaRepository<BankAccount, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from BankAccount a where a.id = :id")
    Optional<BankAccount> findByIdForUpdate(Long id);
}

Service Using Pessimistic Lock

@Service
public class PessimisticAccountService {

    private final BankAccountRepository repository;

    public PessimisticAccountService(BankAccountRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public void withdraw(Long accountId, double amount) {

        BankAccount account =
                repository.findByIdForUpdate(accountId)
                        .orElseThrow();

        if (account.getBalance() < amount) {
            throw new RuntimeException("Insufficient balance");
        }

        account.setBalance(account.getBalance() - amount);
    }
}

Generated SQL

For many databases:

SELECT *
FROM bank_accounts
WHERE id = 1
FOR UPDATE;

Then:

UPDATE bank_accounts
SET balance = ?
WHERE id = ?;

Pessimistic Write Flow

flowchart TD

A["Transaction Starts"]

B["Select Row For Update"]

C["Database Locks Row"]

D["Modify Entity"]

E["Commit Transaction"]

F["Lock Released"]

A --> B
B --> C
C --> D
D --> E
E --> F

PESSIMISTIC_READ Example

Use when many transactions can read, but writes should be blocked.

@Lock(LockModeType.PESSIMISTIC_READ)
@Query("select a from BankAccount a where a.id = :id")
Optional<BankAccount> findByIdWithReadLock(Long id);

Meaning:

Other readers may continue.
Writers may wait.

Database behavior depends on the database engine.


PESSIMISTIC_FORCE_INCREMENT Example

Locks row and increments version.

@Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
@Query("select a from BankAccount a where a.id = :id")
Optional<BankAccount> findByIdAndIncrementVersion(Long id);

Use when:

You want pessimistic lock
+
version increment

EntityManager Lock Example

@Transactional
public void withdrawUsingEntityManager(Long accountId, double amount) {

    BankAccount account =
            entityManager.find(
                    BankAccount.class,
                    accountId,
                    LockModeType.PESSIMISTIC_WRITE
            );

    account.setBalance(account.getBalance() - amount);
}

Lock Timeout Example

Sometimes we do not want to wait forever.

import jakarta.persistence.QueryHint;
import org.springframework.data.jpa.repository.QueryHints;

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
    @QueryHint(
        name = "jakarta.persistence.lock.timeout",
        value = "3000"
    )
})
@Query("select a from BankAccount a where a.id = :id")
Optional<BankAccount> findByIdForUpdateWithTimeout(Long id);

Meaning:

Wait maximum 3 seconds for lock.
If lock not available, fail.

Lock Timeout Flow

flowchart TD

A["Transaction B Requests Lock"]

B["Row Already Locked By Transaction A"]

C["Wait Up To 3 Seconds"]

D["Lock Available"]

E["Continue"]

F["Timeout"]

G["Throw Exception"]

A --> B
B --> C
C --> D
D --> E
C --> F
F --> G

Inventory Example

Entity

@Entity
@Table(name = "products")
public class Product {

    @Id
    private Long id;

    private String name;

    private Integer availableQuantity;

    @Version
    private Integer version;

    // getters and setters
}

Optimistic Inventory Update

@Transactional
public void buyProductOptimistic(Long productId, int quantity) {

    Product product =
            productRepository.findById(productId)
                    .orElseThrow();

    if (product.getAvailableQuantity() < quantity) {
        throw new RuntimeException("Out of stock");
    }

    product.setAvailableQuantity(
            product.getAvailableQuantity() - quantity
    );
}

Works well when conflict is rare.


Pessimistic Inventory Update

public interface ProductRepository
        extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Product p where p.id = :id")
    Optional<Product> findByIdForUpdate(Long id);
}
@Transactional
public void buyProductPessimistic(Long productId, int quantity) {

    Product product =
            productRepository.findByIdForUpdate(productId)
                    .orElseThrow();

    if (product.getAvailableQuantity() < quantity) {
        throw new RuntimeException("Out of stock");
    }

    product.setAvailableQuantity(
            product.getAvailableQuantity() - quantity
    );
}

Better for flash sale or high-conflict stock updates.


Inventory Locking Diagram

flowchart TD

A["Product Quantity 1"]

B["User A Buy"]

C["User B Buy"]

D["Optimistic Locking"]

E["Both Read Quantity 1"]

F["One Update Succeeds"]

G["Other Fails Version Check"]

H["Pessimistic Locking"]

I["User A Locks Product"]

J["User B Waits"]

K["User A Buys"]

L["User B Checks Latest Quantity"]

M["Out Of Stock"]

A --> B
A --> C

B --> D
C --> D
D --> E
E --> F
F --> G

B --> H
C --> H
H --> I
I --> J
J --> K
K --> L
L --> M

Seat Booking Example

Entity

@Entity
@Table(name = "seats")
public class Seat {

    @Id
    private Long id;

    private String seatNumber;

    private boolean booked;

    @Version
    private Integer version;

    // getters and setters
}

Pessimistic Seat Booking

public interface SeatRepository
        extends JpaRepository<Seat, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Seat s where s.id = :id")
    Optional<Seat> findSeatForBooking(Long id);
}
@Transactional
public void bookSeat(Long seatId) {

    Seat seat =
            seatRepository.findSeatForBooking(seatId)
                    .orElseThrow();

    if (seat.isBooked()) {
        throw new RuntimeException("Seat already booked");
    }

    seat.setBooked(true);
}

This prevents two users from booking the same seat.


Optimistic vs Pessimistic Timeline

flowchart LR

A["Optimistic"]

B["Read Without Lock"]

C["Work"]

D["Check Version At Commit"]

E["Conflict Fails"]

F["Pessimistic"]

G["Lock Row First"]

H["Work"]

I["Commit"]

J["Release Lock"]

A --> B
B --> C
C --> D
D --> E

F --> G
G --> H
H --> I
I --> J

When to Choose Which?

Scenario Better Choice
User profile update Optimistic
Blog post edit Optimistic
Product catalog update Optimistic
Bank withdrawal Pessimistic
Seat booking Pessimistic
Flash sale inventory Pessimistic
Mostly read system Optimistic
High conflict writes Pessimistic

Common Exceptions

Optimistic Locking

OptimisticLockException
ObjectOptimisticLockingFailureException
StaleObjectStateException

Pessimistic Locking

PessimisticLockException
LockTimeoutException
CannotAcquireLockException
DeadlockLoserDataAccessException

Deadlock Risk in Pessimistic Locking

Deadlock can happen when transactions lock rows in different order.

Transaction A:

Locks Account 1
Waits for Account 2

Transaction B:

Locks Account 2
Waits for Account 1

Deadlock.


Deadlock Diagram

flowchart TD

A["Transaction A Locks Account 1"]

B["Transaction B Locks Account 2"]

C["Transaction A Waits For Account 2"]

D["Transaction B Waits For Account 1"]

E["Deadlock"]

A --> C
B --> D
C --> E
D --> E

Avoiding Deadlocks

✅ Lock rows in same order

✅ Keep transactions short

✅ Add lock timeout

✅ Avoid user interaction inside transaction

✅ Avoid external API calls inside transaction

✅ Use indexes for locked queries


Money Transfer Example

@Transactional
public void transfer(Long fromId, Long toId, double amount) {

    Long firstId = Math.min(fromId, toId);
    Long secondId = Math.max(fromId, toId);

    BankAccount first =
            repository.findByIdForUpdate(firstId).orElseThrow();

    BankAccount second =
            repository.findByIdForUpdate(secondId).orElseThrow();

    BankAccount from =
            first.getId().equals(fromId) ? first : second;

    BankAccount to =
            first.getId().equals(toId) ? first : second;

    if (from.getBalance() < amount) {
        throw new RuntimeException("Insufficient balance");
    }

    from.setBalance(from.getBalance() - amount);
    to.setBalance(to.getBalance() + amount);
}

Why sort IDs?

Always lock accounts in same order.
This reduces deadlock risk.

Full Decision Diagram

flowchart TD

A["Need To Update Same Data Concurrently"]

B["Conflict Rare"]

C["Use Optimistic Locking"]

D["Conflict Frequent"]

E["Use Pessimistic Locking"]

F["Need Strict Sequential Updates"]

G["Use PESSIMISTIC_WRITE"]

H["Read Heavy Entity"]

I["Use Version Field"]

A --> B
B --> C

A --> D
D --> E

E --> F
F --> G

C --> H
H --> I

Best Practices

✅ Use @Version for most entities that can be updated by users

✅ Use optimistic locking by default for read-heavy systems

✅ Use pessimistic locking only for high-conflict critical updates

✅ Keep pessimistic transactions very short

✅ Add lock timeout for pessimistic locks

✅ Lock rows in consistent order

✅ Handle retry for optimistic conflicts

❌ Do not hold DB locks during external API calls

❌ Do not use pessimistic locking everywhere

❌ Do not ignore optimistic locking exceptions


Interview Questions

Q1. What is optimistic locking?

Optimistic locking uses a version column to detect concurrent updates during commit.


Q2. What is pessimistic locking?

Pessimistic locking locks the database row before update so other transactions wait.


Q3. How do we enable optimistic locking in JPA?

@Version
private Integer version;

Q4. Which SQL is generated by optimistic locking?

UPDATE table
SET data = ?, version = ?
WHERE id = ?
AND version = ?;

Q5. When should we use pessimistic locking?

When conflicts are frequent and update correctness is critical, such as inventory, seat booking, and financial balance updates.


Q6. What is PESSIMISTIC_WRITE?

It creates an exclusive database lock, usually using SELECT FOR UPDATE.


Q7. What is the main risk of pessimistic locking?

Deadlocks
Long waits
Reduced throughput

Q8. What is the main risk of optimistic locking?

Update failure during commit
Need retry handling

Summary

Locking protects data from concurrent update problems.

Optimistic Locking
    No DB lock during read
    Uses version check during update
    Best when conflict is rare

Pessimistic Locking
    Locks DB row before update
    Other transactions wait
    Best when conflict is likely

Simple rule:

Use Optimistic Locking by default.
Use Pessimistic Locking for critical high-conflict updates.

For most enterprise Spring Boot applications:

@Version
+
@Transactional
+
Proper exception handling

is the best starting point.