Why interviewers love this: it checks whether you know how to stop two requests from stepping on the same database row.
Question: What is pessimistic locking in Spring Boot / Spring Data JPA?
Answer: Pessimistic locking means you lock a row before changing it, so other transactions must wait or fail instead of reading and updating the same data at the same time. In Spring Data JPA, this is usually done with @Lock or with EntityManager plus a lock mode like PESSIMISTIC_WRITE. It is useful when conflicts are common and a lost update would be expensive, such as stock, money, or seat reservations.
Interview-Ready Answer: I use pessimistic locking when I want to protect a hot row from concurrent updates. In Spring Data JPA, I can mark a query with @Lock(LockModeType.PESSIMISTIC_WRITE) or use EntityManager.find(..., PESSIMISTIC_WRITE), and I make sure the code runs inside a transaction. That tells the database to hold a row lock until commit, so the next transaction waits or times out instead of causing a lost update. The trade-off is lower throughput under contention, so I use it for things like inventory or payment rows, not for long-running read-heavy work.
Pessimistic locking is a simple idea: lock first, then work. The database puts a lock on the row so another transaction cannot safely change it at the same time. In Spring Boot, the app does not own the lock; Hibernate/JPA asks the database for it, and the database enforces it.
SELECT ... FOR UPDATE or a database-specific equivalent.PESSIMISTIC_FORCE_INCREMENT, JPA also bumps the version column, which is handy when you want both locking and version tracking.Use it when the same row is updated often and conflicts are likely: inventory counts, bank balances, coupons, booking systems, rate-limit counters, and ledger-like updates. It is a good choice when waiting a little is better than retrying a failed write or fixing a corrupted business state.
Do not use it casually for slow work. If you lock a row and then call another service, wait on user input, or do heavy computation, you are holding a database lock and a JDBC connection for too long. That hurts throughput fast.
PESSIMISTIC_READ: usually protects against concurrent writes while allowing safe reads, but exact behavior depends on the database.PESSIMISTIC_WRITE: exclusive lock for updates; this is the most common choice for “reserve and decrement” flows.PESSIMISTIC_FORCE_INCREMENT: locks the row and increments the version field.| Aspect | Pessimistic | Optimistic |
|---|---|---|
| Idea | Lock first | Check later |
| Best for | Hot rows | Rare conflicts |
| Conflict handling | Wait or timeout | Fail on save |
| Throughput | Lower under contention | Usually higher |
| Typical error | Lock timeout | Optimistic lock exception |
This is not a classic algorithm complexity question, but the practical cost is easy to explain: app-side work is roughly O(1), while wait time can be unbounded unless you set a timeout. Space is also O(1) in your code, but the database keeps lock metadata internally.
Real-world numbers matter in interviews: a common HikariCP max pool size is 10 by default, so ten blocked requests can exhaust your connection pool. Many teams set lock waits to something like 1-5 seconds to fail fast instead of letting requests hang forever. In JPA, the lock timeout hint is commonly set with jakarta.persistence.lock.timeout in milliseconds, but provider support can vary.
Imagine a checkout service for a flash sale. One product has exactly one item left in stock. Two customers click “Buy” at the same time. Without pessimistic locking, both requests can read stock = 1, both decide it is safe to buy, and you end up overselling the item. The user sees a success screen, but later support has to cancel one order and refund money.
With pessimistic locking, the first request locks the inventory row, decrements it, and commits. The second request waits; when it finally gets the row, it sees stock = 0 and cleanly fails with a business message like “out of stock.”
What goes wrong when people misunderstand it: a developer holds the lock while calling a payment gateway or waiting on an external API. Suddenly requests pile up, Hikari logs show connection starvation, API latency spikes, and the app may return lock timeout errors or 500s. In the logs you often see messages like PessimisticLockException, LockTimeoutException, or database-specific lock wait errors. The symptom is not just “slow code”; it is a traffic jam caused by holding a database row like a parking spot with a cone on it.
package com.example.pessimisticlocking;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.LockModeType;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.Table;
import org.springframework.beans.factory.annotation.Autowired;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
@SpringBootApplication
public class PessimisticLockingApplication {
public static void main(String[] args) {
SpringApplication.run(PessimisticLockingApplication.class, args);
}
@Bean
CommandLineRunner demo(ReservationService service) {
return args -> {
// With spring-boot-starter-data-jpa + an embedded H2 database on the classpath,
// this app starts, creates the table, and demonstrates a locked row.
Long productId = service.createProduct("Mechanical Keyboard", 1);
System.out.println("Seeded product id = " + productId);
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch startGate = new CountDownLatch(1);
// Both threads start together so we can see the contention clearly.
Callable<Boolean> slowBuyer = () -> {
startGate.await();
// This thread holds the row lock for 3 seconds.
return service.reserveOne(productId, 3000, null);
};
Callable<Boolean> impatientBuyer = () -> {
startGate.await();
// Ask the DB to fail fast instead of waiting forever.
return service.reserveOne(productId, 0, 1000);
};
Future<Boolean> first = pool.submit(slowBuyer);
Future<Boolean> second = pool.submit(impatientBuyer);
startGate.countDown();
printOutcome("Buyer #1", first);
printOutcome("Buyer #2", second);
System.out.println("Final stock = " + service.getStock(productId));
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
};
}
private static void printOutcome(String label, Future<Boolean> future) {
try {
System.out.println(label + " result = " + future.get());
} catch (ExecutionException e) {
Throwable root = e.getCause();
System.out.println(label + " failed with " + root.getClass().getSimpleName() + ": " + root.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(label + " interrupted");
}
}
}
@Entity
@Table(name = "products")
class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int stock;
protected Product() {
// JPA needs a no-arg constructor.
}
Product(String name, int stock) {
this.name = name;
this.stock = stock;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public int getStock() {
return stock;
}
public void setStock(int stock) {
this.stock = stock;
}
}
@Service
class ReservationService {
@PersistenceContext
private EntityManager em;
@Transactional
public Long createProduct(String name, int stock) {
Product product = new Product(name, stock);
em.persist(product);
em.flush(); // Forces INSERT now so the ID is available for the demo.
return product.getId();
}
@Transactional
public boolean reserveOne(Long id, long holdMillis, Integer lockTimeoutMillis) {
Map<String, Object> hints = new HashMap<>();
if (lockTimeoutMillis != null) {
// JPA lock timeout is commonly expressed in milliseconds.
hints.put("jakarta.persistence.lock.timeout", lockTimeoutMillis);
}
Product product = em.find(Product.class, id, LockModeType.PESSIMISTIC_WRITE, hints);
if (product == null) {
throw new IllegalArgumentException("Product not found: " + id);
}
System.out.printf("%s locked '%s' with stock=%d%n",
Thread.currentThread().getName(), product.getName(), product.getStock());
if (holdMillis > 0) {
sleepQuietly(holdMillis);
}
// This check happens while the row is still locked, so the value is trustworthy.
if (product.getStock() <= 0) {
System.out.printf("%s saw no stock and will roll back%n", Thread.currentThread().getName());
return false;
}
product.setStock(product.getStock() - 1);
System.out.printf("%s reserved one item; new stock=%d%n",
Thread.currentThread().getName(), product.getStock());
return true;
}
@Transactional(readOnly = true)
public int getStock(Long id) {
Product product = em.find(Product.class, id);
return product == null ? -1 : product.getStock();
}
private void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}Follow-up & Tricky Questions:
@Lock(LockModeType.PESSIMISTIC_WRITE) or use EntityManager with a pessimistic lock mode. The key is that the method must run inside a transaction so the lock is held until commit.PESSIMISTIC_READ and PESSIMISTIC_WRITE? PESSIMISTIC_WRITE is the safer, more exclusive option for updates. PESSIMISTIC_READ is lighter, but the exact blocking behavior depends on the database and is not something to guess about in interviews.jakarta.persistence.lock.timeout, and handle the resulting timeout exception gracefully. In production, failing fast is often better than letting requests pile up.PessimisticLockException and LockTimeoutException, plus vendor-specific exceptions wrapped by Spring. The exact class can vary by provider and database.findById() lock by default? No. Normal reads do not lock rows. You must explicitly ask for a lock with @Lock or the JPA lock APIs.Tricky: Can I use pessimistic locking without @Transactional? Not really. A lock only has meaning while a transaction is open; without a transaction, the lock is released immediately or never held in a useful way.
Tricky: Does pessimistic locking make lost updates impossible? It makes them much less likely for the locked row, but only if every competing path uses the same locking discipline. A single unchecked path that updates without the lock can still break your data.
Tricky: Is pessimistic locking always faster because it avoids retries? No. Under low contention, optimistic locking often wins because it avoids blocking. Pessimistic locking shines when collisions are frequent and retries would be more expensive than waiting.
Common Mistakes:
@Transactional, or it will not protect your update.Memory Hook: Think of the row as a parking spot and pessimistic locking as putting a cone on it. You are saying, “this spot is taken, wait your turn.”
Cheat Sheet:
@Lock(PESSIMISTIC_WRITE) or EntityManager.Practice Tasks:
@Lock(PESSIMISTIC_WRITE) for the same product flow.