Interviewers love this question because it reveals whether you can stop silent overwrites without forcing everyone to wait in line.
Question: What is optimistic locking in Spring Boot, and how does Spring Data JPA use it?
Answer: Optimistic locking is a way to detect that two transactions tried to change the same row, instead of blocking one of them up front. In Spring Data JPA, you usually add a @Version field to the entity, and Hibernate checks that version when it updates the row. If someone else already changed the row, your update fails instead of silently overwriting their work.
Interview-Ready Answer: I use optimistic locking when I want to prevent lost updates without locking rows for a long time. In Spring Data JPA, I add a @Version field, and Hibernate includes that version in the UPDATE statement. If the row changed after I read it, the update affects zero rows and Spring throws an optimistic locking exception, which I can handle by retrying or asking the user to refresh. It is a great fit for read-heavy systems because readers do not block each other.
Optimistic locking is a safety check for concurrent updates. The word optimistic means the system assumes conflicts are rare, so it does not lock the row while you are reading it. Instead, it remembers a version number and verifies that the row is still the same when you write it back.
@Version private Long version;.flush or transaction commit, Hibernate sends SQL like update product set name=?, version=? where id=? and version=?.where clause matches zero rows, and JPA raises OptimisticLockException; Spring usually translates that into ObjectOptimisticLockingFailureException or OptimisticLockingFailureException.Use optimistic locking when many requests read data but only some write it, like profile edits, product administration, or order adjustments. It is especially useful when the user can reasonably retry, refresh, or merge changes. It keeps throughput high because reads do not block each other.
Do not use it as a magical cure for all concurrency problems. It protects against lost updates meaning one user overwriting another user’s change, but it does not replace transaction isolation for dirty reads or phantom reads.
| Optimistic | Pessimistic |
|---|---|
| No blocking on read | Locks row early |
| Fails on conflict | Waits for lock |
| Best for low contention | Best for hot rows |
| Retry friendly | Deadlock risk |
The normal cost is tiny: one extra version column and one extra comparison in the UPDATE statement, so the write path stays O(1). The expensive part is conflict handling, because a failed write means another round trip and usually a retry; a common retry policy is 2 to 3 attempts with a small backoff such as 50 to 200 ms.
A few important gotchas matter in interviews. First, optimistic locking checks writes, not reads, so two users can still open the same record at the same time. Second, bulk JPQL updates and native SQL updates can bypass entity version checks unless you handle versioning yourself. Third, do not manually set the version field; the JPA provider owns it. Finally, the version value is provider-managed, so do not rely on a specific starting number; focus on the fact that it changes on update.
Think of it like a museum ticket stub: when you leave the room, your ticket has a number, and the guard checks that the number is still current when you come back. If someone already used the latest ticket, your old one is rejected. That is the whole mental model: read freely, write carefully, verify the ticket.
Real-World Story: Imagine a checkout service for a flash-sale store. Two customers try to buy the last wireless headset at almost the same time. Both load the same inventory row, both think there is one item left, and both try to reserve it. With optimistic locking, the first update succeeds and bumps the version; the second update fails with a stale-data error instead of silently overselling the product.
In production, this usually shows up as a controlled conflict rather than a disaster. The API returns a 409 Conflict or a friendly message like 'please refresh and try again,' and the log contains a Spring exception such as ObjectOptimisticLockingFailureException. If the team misunderstands the concept and removes @Version, the bug becomes much worse: orders may both succeed, inventory goes negative, payment is captured for stock that no longer exists, and support sees angry users reporting that the site sold them an item that was already gone.
The key lesson is that optimistic locking does not prevent competition; it makes competition visible and safe.
package com.example.optimisticlocking;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@SpringBootApplication
public class OptimisticLockingApplication {
public static void main(String[] args) {
SpringApplication.run(OptimisticLockingApplication.class, args);
}
@Bean
CommandLineRunner demo(InventoryItemRepository repository, InventoryService service) {
return args -> {
// Create one row with version = 0/1 depending on the JPA provider.
InventoryItem created = repository.saveAndFlush(new InventoryItem(null, "Wireless Mouse", 10));
Long id = created.getId();
// This object becomes stale after another transaction updates the same row.
InventoryItem staleCopy = service.loadDetached(id);
// Simulate another user changing the same row first.
service.reserveOne(id);
// This stale object still carries the old version, so the database should reject it.
staleCopy.setQuantity(8);
staleCopy.setProductName("Wireless Mouse - stale edit");
try {
service.saveDetached(staleCopy);
System.out.println("Unexpected success: stale update was accepted.");
} catch (OptimisticLockingFailureException ex) {
System.out.println("Expected optimistic locking failure: " + ex.getClass().getSimpleName());
}
InventoryItem latest = repository.findById(id).orElseThrow();
System.out.println("Final row in database: " + latest);
};
}
}
@Entity
@Table(name = "inventory_items")
class InventoryItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// JPA owns this field. Hibernate checks it in the WHERE clause during UPDATE/DELETE.
@Version
private Long version;
private String productName;
private int quantity;
public InventoryItem() {
}
public InventoryItem(Long id, String productName, int quantity) {
this.id = id;
this.productName = productName;
this.quantity = quantity;
}
public Long getId() {
return id;
}
public Long getVersion() {
return version;
}
public String getProductName() {
return productName;
}
public void setProductName(String productName) {
this.productName = productName;
}
public int getQuantity() {
return quantity;
}
public void setQuantity(int quantity) {
this.quantity = quantity;
}
@Override
public String toString() {
return "InventoryItem{id=" + id + ", version=" + version + ", productName='" + productName + "', quantity=" + quantity + "}";
}
}
interface InventoryItemRepository extends JpaRepository<InventoryItem, Long> {
}
@Service
class InventoryService {
private final InventoryItemRepository repository;
InventoryService(InventoryItemRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public InventoryItem loadDetached(Long id) {
// Returned entity becomes detached after the transaction ends, which makes the stale-copy demo realistic.
return repository.findById(id).orElseThrow();
}
@Transactional
public InventoryItem reserveOne(Long id) {
InventoryItem item = repository.findById(id).orElseThrow();
if (item.getQuantity() <= 0) {
throw new IllegalStateException("Out of stock");
}
item.setQuantity(item.getQuantity() - 1);
// saveAndFlush() forces the SQL now, so the version check happens inside this method.
return repository.saveAndFlush(item);
}
@Transactional
public InventoryItem saveDetached(InventoryItem item) {
// merge + flush: if the version is stale, Hibernate throws before the method returns.
return repository.saveAndFlush(item);
}
}
Follow-up & Tricky Questions:
@Version and @Lock(LockModeType.OPTIMISTIC)? @Version defines the version column that makes optimistic locking work. @Lock asks JPA to use a specific lock mode for a query; the common one is still based on version checking.saveAndFlush makes the failure show up earlier and more predictably.findById at the same time, does one fail? No. Both can read the row successfully; the failure happens later when they both try to save conflicting changes.save() on a stale entity, will I always see the exception immediately? Not always. Without an explicit flush, the error may surface during transaction commit, so the method can look successful until the transaction closes.Common Mistakes:
@Version and assuming JPA will detect lost updates automatically. Correction: without a version column, the last write usually wins silently.Memory Hook: Read with trust, write with a ticket. You can look at the row freely, but when you write, JPA checks whether your version ticket is still the latest one.
Cheat Sheet:
@Version is the core feature.UPDATE WHERE clause.Practice Tasks:
@Version field to one of your own entities and print the version before and after an update.