skip to content

Pessimistic Locking

Taking real database row locks through LockModeType and living with the consequences — timeouts, deadlock exposure, and vendor differences. Interviewers ask when pessimistic beats optimistic and how to bound the wait.

part ofHibernateoverview, primer and where to startread it →
on this pageshow

questions

6

In plain JPA/Hibernate, how do you tell the persistence provider to read an entity while holding a database row lock for the rest of the transaction, and what does it actually send to the database?

level: juniorimportance: must knowfreq 55%

answer

  1. find(id, LockModeType) = read + lock in one SQL
  2. setLockMode on a query; em.lock upgrades a managed entity
  3. FOR UPDATE, held until commit/rollback
  4. no transaction → TransactionRequiredException
  5. pessimistic read bypasses the L2 cache

basics

~20 s

Pass a pessimistic lock mode on the read: em.find(Order.class, id, LockModeType.PESSIMISTIC_WRITE), query.setLockMode(...), or em.lock(managedEntity, ...). Hibernate emits SELECT ... FOR UPDATE. A transaction must be active, and the database holds the lock until commit or rollback.

solid answer

~40 s

Three entry points, all taking a `LockModeType`: - `em.find(Order.class, id, LockModeType.PESSIMISTIC_WRITE)` — loads and locks in one statement. - `query.setLockMode(LockModeType.PESSIMISTIC_WRITE)` on a JPQL/Criteria query — locks every row the query returns. - `em.lock(order, LockModeType.PESSIMISTIC_WRITE)` — locks an entity already managed in this persistence context. Hibernate maps the mode through the dialect to `SELECT ... FOR UPDATE` (`FOR SHARE` for `PESSIMISTIC_READ`). A pessimistic read goes to the database and bypasses the second-level cache — a cached copy proves nothing about who holds the row now. An active transaction is mandatory; without one JPA throws `TransactionRequiredException`. The lock belongs to the database, not to Hibernate, so only commit or rollback releases it — clearing or detaching the entity does not. Native equivalent: `session.find(Order.class, id, LockMode.PESSIMISTIC_WRITE)`.

code

java · 16 lines
java
em.getTransaction().begin();

// 1. read and lock in one statement
Order order = em.find(Order.class, id, LockModeType.PESSIMISTIC_WRITE);

// 2. lock every row a query returns
List<Order> batch = em.createQuery(
        "select o from Order o where o.status = :s", Order.class)
    .setParameter("s", Status.NEW)
    .setLockMode(LockModeType.PESSIMISTIC_WRITE)
    .getResultList();

// 3. upgrade an entity already managed here
em.lock(order, LockModeType.PESSIMISTIC_WRITE);

em.getTransaction().commit(); // locks released here

go deeper

for a junior

Name the three call sites and say the generated SQL is SELECT ... FOR UPDATE inside an active transaction, released at commit.

for a middle

Add that the lock is the database's, held to the end of the transaction, that the L2 cache is bypassed, and that a missing transaction throws TransactionRequiredException.

for a senior

Discuss lock duration as a throughput budget, the staleness gap when locking an already-loaded unversioned entity, and why FOR UPDATE plus outer joins is invalid SQL on most engines.

for a principal

Frame it as choosing where serialization happens — a lock held across slow work converts a concurrency problem into a queueing problem, so bound the critical section and the connection hold time by design.

## The idea Pessimistic locking assumes a conflict is likely, so it takes a lock on the row *before* the read-modify-write and makes competing transactions wait. Serialization happens in the database, up front, rather than being detected when you try to write. ## The three ways to ask **Locking find.** `em.find(Order.class, id, LockModeType.PESSIMISTIC_WRITE)` issues one statement — `select ... from orders where id = ? for update`. This is the safest form: the row is read and locked atomically, so what you hold in memory is the state as of the moment the lock was granted. **Query-level lock.** `TypedQuery.setLockMode(...)` appends the `FOR UPDATE` clause to the generated query, locking every row in the result set. Watch out: most databases reject `FOR UPDATE` combined with an outer join, `DISTINCT` or `GROUP BY`, so a query that also `join fetch`es a collection will usually fail or lock the wrong table. **Locking an already-managed entity.** `em.lock(order, LockModeType.PESSIMISTIC_WRITE)` sends a separate lock statement for an entity loaded earlier in the same transaction. The subtlety: the copy in your persistence context was read *before* the lock existed, so it may already be stale. For an entity with a version attribute Hibernate puts the version in the lock statement's `where` clause and fails with an optimistic-lock error if the row moved on; for an entity with no version there is no such check, so follow the lock with `em.refresh(...)` if the values matter. ## What comes out on the wire The dialect decides the exact syntax: `for update` on PostgreSQL/Oracle/MySQL, `with (updlock, rowlock)` style hints on SQL Server. When several tables are involved Hibernate may render `for update of <alias>` so only the target table's rows are locked. ## Transaction and cache rules A pessimistic lock has no meaning outside a transaction, so JPA mandates `TransactionRequiredException` when none is active. The lock lives until the transaction ends — Hibernate cannot release it early, and there is no `unlock()`. That makes lock duration a design concern: everything you do between acquiring the lock and committing is time other requests spend blocked, and the JDBC connection is pinned for that whole window. Because correctness depends on the current row, Hibernate skips the second-level cache for pessimistically locked reads and goes to the database. ## Practical shape The usual pattern is: begin transaction, locking-find the row, validate and mutate, commit. Keep external calls (HTTP, mail, file IO) outside that window; a lock held across a slow remote call is how a healthy system turns into a queue of blocked threads.

  • What happens if you call em.lock with a pessimistic mode outside a transaction?
    JPA specifies `TransactionRequiredException`. A row lock only exists inside a database transaction, so there is nothing meaningful the provider could do — it cannot acquire a lock that would be released immediately by an autocommit statement. The same rule applies to a locking `find` and to `Query.setLockMode`.
  • Does em.lock refresh the entity you already loaded?
    Not in general. Hibernate sends a lock statement for the row, but the field values in your persistence context are still the ones read earlier. If the entity has a version attribute, Hibernate includes it in the lock statement and throws an optimistic-lock failure when the row changed; without a version you can silently be holding stale values, so either use a locking `find` from the start or call `em.refresh` after locking.

saying these in an interview costs you the question

  • Thinking Hibernate itself holds the lock in the JVM, so clearing the persistence context or detaching the entity releases it.
  • Believing a lock can be released before commit, or looking for an unlock() method.
  • Expecting a pessimistic read to be served from the second-level cache.
  • Adding join fetch to a query with setLockMode and assuming FOR UPDATE will still be valid SQL.

context

open as a page

What is the difference between JPA's LockModeType.PESSIMISTIC_READ and LockModeType.PESSIMISTIC_WRITE, and what SQL does Hibernate generate for each?

level: middleimportance: must knowfreq 50%

basics

~20 s

PESSIMISTIC_READ takes a shared lock (SELECT ... FOR SHARE): other readers may also lock it, writers block. PESSIMISTIC_WRITE takes an exclusive lock (SELECT ... FOR UPDATE): nobody else may lock or modify the row. On dialects without shared locks, Hibernate upgrades READ to FOR UPDATE.

open as a page

A pessimistic read in JPA blocks indefinitely while another transaction holds the row. Which standard JPA hint bounds that wait, what special values does Hibernate recognise for it, and which exception surfaces when the wait expires?

level: seniorimportance: must knowfreq 42%

basics

~20 s

The hint jakarta.persistence.lock.timeout, in milliseconds. Hibernate reads 0 as NOWAIT (fail instantly) and -2 as SKIP LOCKED (skip contended rows); -1 waits forever. Expiry raises LockTimeoutException, which — unlike PessimisticLockException — does not mark the transaction for rollback.

open as a page

What does JPA's LockModeType.PESSIMISTIC_FORCE_INCREMENT do that LockModeType.PESSIMISTIC_WRITE does not, and when would you reach for it?

level: seniorimportance: should knowfreq 30%

basics

~20 s

It takes the exclusive row lock and also bumps the entity's version column immediately, in the same operation, even if you never modify the entity. Use it to invalidate other transactions' copies of a parent whose children you are changing. The entity must have a version attribute.

open as a page

Several application instances poll the same jobs table for pending work. Using only JPA/Hibernate locking, how would you design the claim step so no two instances process the same row, and what are the trade-offs of your design?

level: principalimportance: should knowfreq 28%

basics

~20 s

Claim with a limited query using PESSIMISTIC_WRITE plus the SKIP LOCKED timeout value (-2), mark the rows claimed, and commit quickly — then process outside the lock. Trade-offs: no ordering fairness, dialect support required, and crash recovery needs a lease or heartbeat.

open as a page

JPA's jakarta.persistence.lock.scope hint accepts PessimisticLockScope.NORMAL or PessimisticLockScope.EXTENDED. What extra rows does EXTENDED lock, and what does it still leave unprotected?

level: seniorimportance: nice to knowfreq 18%

basics

~20 s

NORMAL locks the rows that make up the entity itself, including joined-inheritance and secondary-table rows. EXTENDED additionally locks the rows of join tables and element-collection tables the entity owns, so nobody can add or remove elements. It never locks the associated entities' own rows.

open as a page