Solving ObjectOptimisticLockingFailureException in Spring Boot: Strategies for JPA Concurrency

intermediate Java2026-07-14| Java 8+, Spring Boot 2.x/3.x, Hibernate 5/6, PostgreSQL/MySQL/Oracle

Error Message

org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1
#spring-boot#jpa#hibernate#concurrency

The Error ContextYou’ve likely encountered this error during high-traffic periods: everything works fine in development, but the logs explode when two users click 'Save' at the exact same millisecond. This happens because Spring Data JPA uses an @Version field to prevent one transaction from silently overwriting another—a strategy known as Optimistic Locking.

Hibernate expects to update exactly one row. However, if a different thread modified the record first, the version number in the database no longer matches what the current thread holds. The update fails because the WHERE clause finds zero matching rows, triggering the exception:

org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1

A Real-World ExamplePicture an inventory system where an item has 10 units in stock (Version 1). User A and User B both load the page simultaneously. User A buys 2 units. The system updates the stock to 8 and increments the version to 2. A millisecond later, User B tries to buy 1 unit. Their request still thinks the version is 1. When the database looks for id=X AND version=1, it finds nothing because the version is already 2. The update fails.

Root Cause: The Version MismatchThe culprit is almost always a versioning column in your entity. Most JPA setups look like this:

@Entity
public class Inventory {
    @Id
    private Long id;

    private Integer stockCount;

    @Version
    private Long version;
}

Behind the scenes, Hibernate executes a specific SQL query:

UPDATE inventory SET stock_count = ?, version = 2 WHERE id = ? AND version = 1;

If another process changed that version to 2 before this query finished, the WHERE clause returns zero affected rows. Spring sees this and immediately throws the ObjectOptimisticLockingFailureException to protect your data integrity.

How to Fix the Error### Approach 1: Using Spring Retry (The Industry Standard)Since these collisions are usually transient, the simplest fix is to try again. A second attempt will fetch the fresh version from the database and likely succeed. This is seamless for the end-user.

First, add these dependencies to your pom.xml:

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
</dependency>

Enable the retry mechanism in your configuration:

@Configuration
@EnableRetry
public class RetryConfig {
}

Now, simply annotate your service method. Setting maxAttempts = 3 gives the system enough breathing room to resolve the conflict:

@Service
public class InventoryService {

    @Autowired
    private InventoryRepository repository;

    @Retryable(
        retryFor = ObjectOptimisticLockingFailureException.class, 
        maxAttempts = 3, 
        backoff = @Backoff(delay = 100)
    )
    @Transactional
    public void updateStock(Long id, Integer quantity) {
        Inventory inventory = repository.findById(id)
            .orElseThrow(() -> new RuntimeException("Product not found"));
        
        inventory.setStockCount(inventory.getStockCount() - quantity);
        repository.save(inventory);
    }
}

Approach 2: Manual Catch and RecoveryManual handling is better if you need specific logic, like logging a warning or notifying a monitoring system when a collision occurs. It provides more control than the annotation-based approach.

public void safeUpdate(Long id, Integer quantity) {
    int attempts = 0;
    while (attempts < 3) {
        try {
            businessLogicService.updateStock(id, quantity);
            return; 
        } catch (ObjectOptimisticLockingFailureException e) {
            attempts++;
            if (attempts == 3) throw e;
            log.warn("Conflict detected for ID {}. Retrying... attempt {}", id, attempts);
        }
    }
}

Approach 3: Switch to Pessimistic LockingAre you running a flash sale with 500+ concurrent requests per second on a single item? In high-contention scenarios, retrying becomes inefficient. Instead, lock the row at the database level the moment you read it.

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

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT i FROM Inventory i WHERE i.id = :id")
    Optional<Inventory> findByIdForUpdate(@Param("id") Long id);
}

This forces other transactions to wait in a queue. It eliminates the exception entirely but can slow down your application if locks are held too long.

Verification StepsDon't guess—test it. Use a CountDownLatch to force two threads to collide in an integration test. This ensures your retry logic actually works under pressure.

@Test
void testConcurrentUpdate() throws InterruptedException {
    int threadCount = 2;
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        executor.execute(() -> {
            try {
                inventoryService.updateStock(1L, 1);
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await(5, TimeUnit.SECONDS);
    Inventory finalState = repository.findById(1L).get();
    assertEquals(8, finalState.getStockCount()); // 10 original - 2 updates
}

Prevention Tips

- **Keep transactions lean:** Never perform heavy processing or call external APIs inside a `@Transactional` block. The faster the transaction, the lower the chance of a collision.
- **Use Atomic Updates:** For simple math like `stock = stock - 1`, use a native query: `UPDATE inventory SET stock_count = stock_count - :q WHERE id = :id`. This avoids the version check entirely by doing the math inside the database.
- **Check your UI:** Ensure your frontend doesn't send duplicate requests. A simple 'disabled' state on a button after the first click can prevent 50% of these errors.

Related Error Notes