Transaction Isolation & Locking

This section documents Spring Boot 4.1.x and Spring Framework 7.0.x on the Java 17/21+ baseline, as described by the official Spring Boot reference documentation and each sub-project’s own site (Spring Data, Spring for Apache Kafka, Micrometer, Project Reactor, springdoc-openapi, Spring gRPC, and the others listed in this section’s Bibliography) — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production. Spring Boot and its ecosystem continue to evolve; the examples here target the current 4.1.x / 7.0.x releases.

This section’s bibliography lists the reference material consulted while preparing these pages.

@Transactional defines where a transaction starts and ends, and propagation defines how a boundary relates to one already in progress — both covered in Spring Data JPA and Spring Data Overview. This page covers the two settings that decide what happens inside those boundaries when several transactions touch the same data at the same time: the isolation level the transaction runs at, and whether the application takes an explicit pessimistic lock instead of relying on @Version optimistic locking.

The ANSI/SQL isolation model itself — what each level means, exactly which anomaly it permits, and how a concrete engine implements it — belongs to the database, and is covered in SQL Reference: Transaction Control. What follows is the Spring layer over that model: the isolation attribute, the @Lock annotation, how each store’s transaction manager behaves, and how to choose a strategy as a Spring Data developer.

Isolation levels in Spring

@Transactional carries an isolation attribute taking a value of the org.springframework.transaction.annotation.Isolation enum. The PlatformTransactionManager applies it when it starts the transaction — for the relational managers (DataSourceTransactionManager, JpaTransactionManager) that means a Connection.setTransactionIsolation(…​) call on the JDBC connection, reverted when the connection returns to the pool:

@Service
public class ReportService {

    private final OrderRepository orders;

    public ReportService(OrderRepository orders) {
        this.orders = orders;
    }

    @Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
    public MonthlyReport buildReport(Long customerId) {
        // every read in this method sees the same consistent snapshot of the customer's orders
        return MonthlyReport.of(orders.findByCustomerId(customerId));
    }
}

The five enum constants and the read phenomena each one permits:

Isolation constant Dirty read Non-repeatable read Phantom read Notes

DEFAULT

 — 

 — 

 — 

Do not set anything — keep whatever the datasource/database default is. The default value of the attribute.

READ_UNCOMMITTED

permitted

permitted

permitted

Reads uncommitted changes of other transactions. Rarely useful; several engines do not implement it at all.

READ_COMMITTED

prevented

permitted

permitted

Only committed data is visible, but a row re-read within the transaction can have changed.

REPEATABLE_READ

prevented

prevented

permitted

A row re-read within the transaction is unchanged; newly matching rows can still appear.

SERIALIZABLE

prevented

prevented

prevented

The result is equivalent to running the concurrent transactions one after another.

See SQL Reference: Transaction Control for what each phenomenon is, the per-level detail, and how a given engine actually enforces the level (locking, multi-version concurrency control, or serializable snapshot isolation).

Isolation.DEFAULT — the default — means Spring sets nothing and the database’s own default applies. That default differs by engine: PostgreSQL, Oracle, and SQL Server default to READ COMMITTED, MySQL/InnoDB to REPEATABLE READ. Code that depends on a specific level must therefore state it explicitly rather than assume a portable baseline.

Not every level is supported by every engine, and not every engine implements a level the way the ANSI definition describes. Oracle, for instance, accepts only READ COMMITTED and SERIALIZABLE; PostgreSQL accepts READ UNCOMMITTED but silently runs it as READ COMMITTED. A level the driver rejects surfaces as an exception when the transaction starts, not at startup.

How isolation level affects performance

Raising the isolation level never makes a workload faster. It buys stronger guarantees by making the engine do more work, hold more state, or hold it for longer:

  • Lock scope and duration grow with the level. A locking engine holds shared read locks to the end of the transaction at REPEATABLE READ instead of releasing them after each statement, and may escalate to range or predicate locks at SERIALIZABLE so that rows which do not exist yet cannot be inserted by anyone else.

  • MVCC bookkeeping costs more. On a snapshot-based engine the price is paid in old row versions that must be retained while any transaction can still see them, longer version chains to walk on read, and vacuum/undo work deferred until the oldest snapshot closes. A long-running REPEATABLE READ transaction pins that garbage for everybody.

  • Anomalies become explicit failures. The weaker levels let a conflicting interleaving succeed quietly with a slightly wrong answer; the stronger ones convert the same interleaving into something visible — blocking, a lock-wait timeout, a deadlock victim, or a serialization failure. That is the point, but it means the caller now needs a retry path for exceptions that simply never occurred before.

  • Throughput falls as contention rises. The cost of a stricter level is roughly proportional to how often transactions actually collide. On data no two requests touch at once, SERIALIZABLE is nearly free; on a hot row or a hot index range, it can collapse concurrency to one transaction at a time.

Practical consequences for a Spring service:

  • Keep transactions short. Isolation cost is paid per unit of time the transaction stays open, so never span a remote HTTP call, a message round-trip, or user think time inside a @Transactional method.

  • Raise the level on the narrowest method that needs it rather than globally — isolation is a per-boundary attribute precisely so a single report or balance check can be strict while the rest of the application is not.

  • Mark read paths @Transactional(readOnly = true): it lets the JPA provider skip dirty checking and flushing, and lets some drivers and proxies route the work to a replica.

  • If a stricter level is chosen, write the retry. SERIALIZABLE on PostgreSQL, and deadlock resolution on any engine, mean a transaction that has done nothing wrong can still be aborted and must be replayed.

Pessimistic locking

Pessimistic locking takes the database lock up front, on read, and holds it until the transaction commits, so no competing transaction can modify (or, depending on the mode, even read) the row in between. In Spring Data JPA this is the @Lock annotation on a repository query method, with a jakarta.persistence.LockModeType:

public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    Optional<Account> findWithLockById(Long id);
}
@Service
public class WithdrawalService {

    private final AccountRepository accounts;

    public WithdrawalService(AccountRepository accounts) {
        this.accounts = accounts;
    }

    @Transactional
    public void withdraw(Long accountId, BigDecimal amount) {
        Account account = accounts.findWithLockById(accountId).orElseThrow();
        // the row is locked from here until this method's transaction commits --
        // any concurrent withdraw() on the same account waits (up to 3 s) rather than conflicting
        account.setBalance(account.getBalance().subtract(amount));
        accounts.save(account);
    }
}

The lock modes:

LockModeType Meaning

PESSIMISTIC_READ

A shared lock. Other transactions may read the row, none may update it. Use when the value must not change while it is being used to compute something, but this transaction will not write it.

PESSIMISTIC_WRITE

An exclusive lock, emitted as SELECT …​ FOR UPDATE (or the dialect’s equivalent). The common choice for a read-modify-write that must not fail.

PESSIMISTIC_FORCE_INCREMENT

An exclusive lock that additionally bumps the entity’s @Version on commit, even if no field changed. Useful to signal that an aggregate changed when the modification actually happened on a child entity.

Some details worth knowing:

  • The annotated method must run inside a transaction — a lock acquired by a repository call with no surrounding @Transactional boundary is released immediately when that call’s implicit transaction commits, which defeats the purpose.

  • @QueryHints with jakarta.persistence.lock.timeout (milliseconds) caps how long the statement waits for the lock; on expiry the provider throws LockTimeoutException, surfaced by Spring as PessimisticLockingFailureException. A value of 0 means "do not wait at all" where the dialect supports it. Support is dialect-dependent — PostgreSQL and Oracle honour it, several others ignore the hint.

  • Locks taken in an inconsistent order across concurrent transactions produce deadlocks; the engine kills one participant and the application sees a CannotAcquireLockException. Always acquire multiple locks in a stable order (for example, by ascending primary key).

  • The lock is held for the whole remaining transaction, so a pessimistic path must be short. It also does not survive across requests — it cannot protect an edit that spans a user filling in a form.

  • Modes can also be applied outside a repository method, via EntityManager.lock(entity, lockMode) or find(Class, id, lockMode), and to a @Query-annotated method by adding @Lock to it.

Optimistic locking (recap)

Optimistic locking takes no database lock. A @Version field is read with the entity and included as a condition in the write; if a concurrent transaction already committed a change, the write matches zero rows and fails with an exception in the OptimisticLockingFailureException family (JPA surfaces the more specific ObjectOptimisticLockingFailureException). Nothing blocks — the conflict is detected at write time instead of prevented at read time.

Because the conflict surfaces as an exception, the caller owns the recovery: catch it, re-read the current state, re-apply the change, and save again — or translate it into a 409 Conflict for the client to resolve. The mechanism and worked examples are not repeated here; see Spring Data Overview for the cross-store mechanism, and the "Optimistic locking with `@Version`" section of Spring Data JPA, Spring Data MongoDB, Spring Data Couchbase, and Spring Data Neo4j for each store’s concrete behavior.

Optimistic vs. pessimistic: choosing

Optimistic (@Version) Pessimistic (@Lock)

How conflicts surface

At write time, as OptimisticLockingFailureException. The work is already done and must be redone.

They do not — the competing transaction waits at read time, or times out.

Cost under low contention

Effectively zero: one extra column and one extra WHERE predicate.

A real lock acquired on every read, paid for even when nothing would have collided.

Cost under high contention

High: repeated wasted work and retry storms as writers keep losing the race.

Bounded: writers queue and each does its work exactly once.

Deadlock risk

None — no locks are held.

Real. Multiple locks in inconsistent order will deadlock; needs a stable acquisition order.

Scalability

Excellent — readers and writers never block each other.

Limited — throughput on a hot row is capped by one transaction at a time.

Caller complexity

The caller must implement retry (or surface a conflict to the user).

The caller writes straight-line code, but must bound lock time and handle timeouts.

Long "user think time" edits

The only workable option: the version travels with the form and is checked on submit.

Unusable — a database lock cannot be held across requests.

The default should be optimistic: it is cheaper, it cannot deadlock, and it degrades gracefully. Reach for pessimistic locking when contention on a specific row is genuinely high, the transaction touching it is short, and redoing the work on conflict is either expensive or unacceptable — the canonical case being a balance or inventory decrement that must succeed on the first attempt. The two are not exclusive: an entity can carry @Version for its ordinary paths and still be read with @Lock(LockModeType.PESSIMISTIC_WRITE) on the one hot code path that needs it.

flowchart TB Start["Concurrent writes to the same record"] --> Contention{"Are conflicts frequent\nin practice?"} Contention -- "rare" --> Optimistic["Optimistic: @Version + retry"] Contention -- "frequent" --> Length{"Is the transaction short\nand fully server-side?"} Length -- "no (spans requests\nor user think time)" --> Optimistic Length -- "yes" --> Redo{"Is redoing the work on\nconflict expensive or\nunacceptable?"} Redo -- "no" --> Optimistic Redo -- "yes" --> Pessimistic["Pessimistic: @Lock(PESSIMISTIC_WRITE)\n+ lock timeout + stable lock order"]

The same trade-off seen from the database’s side, including how each strategy maps onto engine-level locking, is covered in SQL Reference: Transaction Control under "Choosing between the two strategies" — not restated here.

Per-store support

The isolation attribute is only meaningful for a transaction manager that has somewhere to apply it. Only the relational managers do:

Store isolation Behavior See also

Relational (JPA / JDBC)

Supported

JpaTransactionManager / DataSourceTransactionManager set the level on the JDBC Connection and restore it on release. @Lock pessimistic modes are fully supported. A JpaTransactionManager may need the JPA provider and datasource to allow a non-default level — some connection pools must be configured to permit changing it.

Spring Data JPA, SQL Reference: Transaction Control

MongoDB

Ignored

MongoTransactionManager neither applies nor rejects the attribute — a non-default value is silently ignored. Concurrency behavior is governed instead by read concern, write concern, read preference, and causal consistency. @Version optimistic locking is available.

Spring Data MongoDB, MongoDB Reference: Transactions

Couchbase

Fixed at read-committed

CouchbaseCallbackTransactionManager accepts only DEFAULT or READ_COMMITTED; a stricter value throws IllegalArgumentException. Couchbase distributed transactions provide their own model (staged writes, read-your-own-writes) rather than exposing the ANSI levels. CAS/@Version optimistic locking is available.

Spring Data Couchbase, Couchbase Reference: Concurrency, Locking and Durability

Neo4j

Rejected

Neo4jTransactionManager throws InvalidIsolationLevelException for any non-default value. Neo4j runs at read-committed and takes write locks on the nodes and relationships a transaction modifies, held until commit. @Version optimistic locking is available.

Spring Data Neo4j

None of the three non-relational managers lets you actually raise the isolation level: Couchbase and Neo4j reject a stricter value with an exception when the transaction starts, while MongoDB accepts the attribute and then ignores it — so on MongoDB a mistaken isolation setting fails silently rather than loudly.

Summary

  • @Transactional(isolation = …​) takes an Isolation enum constant; DEFAULT (the default) keeps the database’s own level, which differs by engine — READ COMMITTED on PostgreSQL/Oracle/SQL Server, REPEATABLE READ on MySQL/InnoDB.

  • Raising the level trades throughput for guarantees: longer-held locks, more MVCC bookkeeping, and anomalies converted into blocking, lock timeouts, deadlocks, or serialization failures the caller must retry.

  • @Lock(LockModeType.PESSIMISTIC_WRITE) on a repository method emits SELECT …​ FOR UPDATE and holds the row lock until commit; pair it with a @QueryHints jakarta.persistence.lock.timeout and a stable lock ordering.

  • Prefer optimistic @Version locking by default; use pessimistic locking for short, server-side, high-contention writes whose work must not be redone.

  • Only the relational transaction managers honour isolation: MongoTransactionManager silently ignores it, CouchbaseCallbackTransactionManager allows only READ_COMMITTED, and Neo4jTransactionManager throws InvalidIsolationLevelException on any non-default value.