RadhaPublication
HomeInterviewLearning PathsRole TracksMockBooksPricing
Login / Sign UpLog in

© 2026 Radha Publication. All rights reserved.

Shop on AmazonDonateContactPrivacyTerms
Spring Boot questions
HardSpring Boot#467 min readJul 11, 2026

Pessimistic Locking.

practice
learning
Practice modeTest yourself instead of reading straight through

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.

🧠 Memory Map
Memory map — visual summary of this topic

What it is

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.

How it works under the hood

  1. Your service starts a transaction. This matters because locks are tied to a transaction, not just to a method call.
  2. JPA issues a SQL query with a lock request, often translated to something like SELECT ... FOR UPDATE or a database-specific equivalent.
  3. The database places a row lock. A row lock means only that row is protected; other rows can still be used normally.
  4. If another transaction tries to lock the same row, it blocks, waits, or fails with a timeout depending on the database and your lock timeout setting.
  5. When your transaction commits or rolls back, the lock is released.
  6. If you use PESSIMISTIC_FORCE_INCREMENT, JPA also bumps the version column, which is handy when you want both locking and version tracking.

When and why to use it

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.

Spring Data JPA options

  • 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.

Optimistic vs pessimistic locking

AspectPessimisticOptimistic
IdeaLock firstCheck later
Best forHot rowsRare conflicts
Conflict handlingWait or timeoutFail on save
ThroughputLower under contentionUsually higher
Typical errorLock timeoutOptimistic lock exception

Performance and edge cases

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.

  • Gotcha 1: no transaction means no real lock.
  • Gotcha 2: locks are released only on commit or rollback.
  • Gotcha 3: two transactions locking rows in different orders can deadlock.
  • Gotcha 4: the exact SQL and timeout behavior depends on the database and JPA provider.

Real-world story

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.

Spring Boot
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:

  • How do you apply pessimistic locking in Spring Data JPA? Use a repository query with @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.
  • What is the difference between 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.
  • How do you avoid waiting forever? Set a lock timeout, usually with the JPA hint jakarta.persistence.lock.timeout, and handle the resulting timeout exception gracefully. In production, failing fast is often better than letting requests pile up.
  • What exceptions can you see? Common ones are PessimisticLockException and LockTimeoutException, plus vendor-specific exceptions wrapped by Spring. The exact class can vary by provider and database.
  • When would you choose optimistic locking instead? Choose optimistic locking when conflicts are rare and you want higher throughput. It is usually better for read-heavy flows because you do not block other transactions while you work.
  • Does pessimistic locking prevent deadlocks? No. It reduces certain races, but if two transactions lock rows in different orders, they can still deadlock.
  • Does 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:

  • Forgetting the transaction. Correction: the lock must live inside @Transactional, or it will not protect your update.
  • Locking and then doing slow work. Correction: keep the critical section tiny; read, decide, update, commit.
  • Assuming all databases behave the same. Correction: SQL translation, timeout behavior, and read-lock semantics vary by database and provider.
  • Using it everywhere by default. Correction: use pessimistic locking only for hot rows or expensive conflicts; otherwise optimistic locking is usually simpler and faster.

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 first, then update.
  • Use it for hot rows: stock, money, booking.
  • Spring Data JPA: @Lock(PESSIMISTIC_WRITE) or EntityManager.
  • Must be inside a transaction.
  • Expect blocking, timeouts, and possible deadlocks.
  • Choose optimistic locking when conflicts are rare.

Practice Tasks:

  • Add a repository method with @Lock(PESSIMISTIC_WRITE) for the same product flow.
  • Run two concurrent updates and observe blocking versus timeout behavior.
  • Change the timeout value and see how your logs and exceptions change under contention.
Previous
Back to Questions←→to navigate
Next

Recommended Resources

Recommended Book

Cracking the Coding Interview

189 Programming Questions and Solutions

Recommended Book

Cracking the Coding Interview

189 Programming Questions and Solutions

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(); } } }