Skip to main content
spring internals

Transaction Propagation, Isolation, and the Read-Only Optimization

9 min read Chapter 30 of 78

Propagation levels are not configuration preferences. They are instructions to AbstractPlatformTransactionManager about what to do with the JDBC Connection. Every level maps to specific operations on DataSource, Connection, and in some cases, JDBC Savepoint. This section traces each level from the annotation to the database driver.

REQUIRED: The Default

Propagation.REQUIRED is the default when you write @Transactional without specifying propagation.

Behavior:

  • If no transaction exists: get a Connection from the DataSource, call Connection.setAutoCommit(false), bind the Connection to TransactionSynchronizationManager
  • If a transaction already exists: join it by reusing the same Connection from the ThreadLocal

There is no savepoint created for REQUIRED. The inner method runs in the same transaction. If the inner method throws, the transaction is marked as rollback-only. The outer method cannot commit. Attempting to commit a rollback-only transaction throws UnexpectedRollbackException.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;

    @Transactional  // REQUIRED by default
    public OrderResult createOrder(TenantId tenantId, OrderRequest request) {
        Order order = Order.create(tenantId, request);
        orderRepository.save(order);

        // Also REQUIRED: joins the same transaction
        inventoryService.reserveStock(order);

        return OrderResult.success(order.getId());
    }
}

@Service
@RequiredArgsConstructor
public class InventoryService {

    private final InventoryRepository inventoryRepository;

    @Transactional  // REQUIRED: joins the caller's transaction
    public void reserveStock(Order order) {
        for (OrderLine line : order.getLines()) {
            Inventory inv = inventoryRepository.findBySkuForUpdate(line.getSku());
            if (inv.getAvailable() < line.getQuantity()) {
                throw new InsufficientStockException(line.getSku());
            }
            inv.reserve(line.getQuantity());
            inventoryRepository.save(inv);
        }
    }
}

Both createOrder() and reserveStock() share one Connection, one transaction. If reserveStock() throws InsufficientStockException (a RuntimeException), the transaction is marked rollback-only. The order is not persisted.

REQUIRES_NEW: Independent Transaction

Propagation.REQUIRES_NEW always creates a new transaction, regardless of whether one exists.

At the JDBC level:

  1. TransactionSynchronizationManager unbinds the current ConnectionHolder and stores it in a SuspendedResourcesHolder
  2. A new Connection is obtained from the DataSource
  3. Connection.setAutoCommit(false) on the new connection
  4. The new ConnectionHolder is bound to TransactionSynchronizationManager
  5. After the inner method completes (commit or rollback), the suspended resources are restored

This means two connections are held simultaneously. The outer transaction’s connection is suspended, not returned to the pool. The inner transaction gets a second connection.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final InventoryDeductionService inventoryDeductionService;

    @Transactional
    public OrderResult createOrder(TenantId tenantId, OrderRequest request) {
        Order order = Order.create(tenantId, request);
        orderRepository.save(order);

        // Separate transaction: commits even if order processing fails later
        inventoryDeductionService.deductInventory(order);

        // If this throws after deductInventory committed, inventory is deducted
        // but order is rolled back. Handle with compensation logic.
        notificationService.sendOrderConfirmation(order);

        return OrderResult.success(order.getId());
    }
}

@Service
@RequiredArgsConstructor
public class InventoryDeductionService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void deductInventory(Order order) {
        // Runs in its own transaction, its own Connection
        // Commits independently of the caller
        for (OrderLine line : order.getLines()) {
            inventoryRepository.deduct(line.getSku(), line.getQuantity());
        }
    }
}

The consequence: if sendOrderConfirmation() throws after deductInventory() has committed, inventory is deducted but the order is rolled back. You now have a data inconsistency. REQUIRES_NEW is not free. Use it only when you need independent commit/rollback semantics and you have a compensation strategy for partial failures.

Connection pool sizing must account for REQUIRES_NEW. If you have 20 concurrent requests and each uses REQUIRES_NEW inside a REQUIRED transaction, you need at least 40 connections. Size your pool accordingly or you will deadlock.

NESTED: Savepoints

Propagation.NESTED creates a JDBC Savepoint within the existing transaction:

Connection.setSavepoint("SAVEPOINT_1")

If the nested method rolls back, the rollback goes to the savepoint, not to the start of the outer transaction. The outer transaction can still commit. If the outer transaction rolls back, the savepoint is also rolled back (it is part of the same transaction).

@Transactional
public OrderResult createOrder(TenantId tenantId, OrderRequest request) {
    Order order = Order.create(tenantId, request);
    orderRepository.save(order);

    try {
        loyaltyService.awardPoints(order);  // NESTED
    } catch (LoyaltyException e) {
        // Loyalty points failed, but savepoint is rolled back
        // The order save is still intact
        log.warn("Loyalty points not awarded for order {}", order.getId());
    }

    return OrderResult.success(order.getId());
}

@Service
public class LoyaltyService {

    @Transactional(propagation = Propagation.NESTED)
    public void awardPoints(Order order) {
        // Runs within a savepoint
        // Rollback here does not rollback the outer transaction
        loyaltyRepository.addPoints(order.getTenantId(), order.calculatePoints());
    }
}

Unlike REQUIRES_NEW, NESTED uses a single Connection. No second connection from the pool. No suspension. The savepoint is a marker within the same transaction.

Limitation: not all JDBC drivers support savepoints. Most modern drivers (PostgreSQL, MySQL 5.0+, Oracle, H2) do. Check DatabaseMetaData.supportsSavepoints(). JPA EntityManager does not expose savepoints directly. NESTED works with DataSourceTransactionManager out of the box, but JpaTransactionManager requires setNestedTransactionAllowed(true) and the underlying JDBC driver must support it.

The Minor Propagation Levels

SUPPORTS: If a transaction exists, join it. If none exists, execute non-transactionally. Use case: read methods that should participate in a transaction if one is active but do not need one otherwise.

NOT_SUPPORTED: If a transaction exists, suspend it. Execute non-transactionally. The ConnectionHolder is unbound from TransactionSynchronizationManager. The method gets a separate, auto-commit connection.

MANDATORY: If a transaction exists, join it. If none exists, throw IllegalTransactionStateException. Use this to enforce that a method must be called from within a transaction. Guards against accidental direct invocation.

NEVER: If a transaction exists, throw IllegalTransactionStateException. The inverse of MANDATORY. Use for methods that must not run inside a transaction, such as operations that take exclusive locks for long durations.

readOnly=true: What It Actually Does

@Transactional(readOnly = true) does two things:

1. Hibernate flush mode set to MANUAL

When JpaTransactionManager begins a read-only transaction, it calls Session.setDefaultReadOnly(true) and sets the flush mode to FlushMode.MANUAL. This disables dirty checking entirely. Hibernate does not compare entity snapshots at commit time. For a query returning 10,000 entities, this eliminates 10,000 snapshot comparisons. The performance difference is measurable.

2. JDBC connection hint

Connection.setReadOnly(true) is called on the JDBC connection. This is a hint, not an enforcement. What happens with this hint depends on the driver:

  • PostgreSQL: The driver may route the query to a read replica if configured with a multi-host connection string
  • MySQL: Similar replica routing with the Connector/J driver’s readOnly support
  • Oracle: The hint is generally ignored
  • HikariCP: Can be configured to route read-only connections to a dedicated pool pointing at a replica

The hint does not prevent writes at the JDBC level. It is advisory. But Hibernate’s MANUAL flush mode does prevent writes at the ORM level.

// BROKEN: @Transactional(readOnly=true) on a method that writes
@Service
@RequiredArgsConstructor
public class TenantReportService {

    private final TenantRepository tenantRepository;
    private final ReportCacheRepository reportCacheRepository;

    @Transactional(readOnly = true)
    public TenantReport generateReport(TenantId tenantId) {
        Tenant tenant = tenantRepository.findById(tenantId)
                .orElseThrow(() -> new TenantNotFoundException(tenantId));

        TenantReport report = reportGenerator.generate(tenant);

        // This will fail: flush mode is MANUAL
        // Hibernate will not flush the persist to the database
        reportCacheRepository.save(ReportCache.of(tenantId, report));

        return report;
    }
}

The save() call enqueues an INSERT in the persistence context, but because flush mode is MANUAL, Hibernate never sends the SQL to the database. The report cache is never persisted. No exception is thrown. The data silently disappears. If you call EntityManager.flush() explicitly, Spring throws TransactionRequiredException because the transaction is marked read-only at the JPA level.

// CORRECT: separate read and write paths
@Service
@RequiredArgsConstructor
public class TenantReportService {

    private final TenantRepository tenantRepository;
    private final ReportCacheService reportCacheService;

    @Transactional(readOnly = true)
    public TenantReport generateReport(TenantId tenantId) {
        Tenant tenant = tenantRepository.findById(tenantId)
                .orElseThrow(() -> new TenantNotFoundException(tenantId));
        return reportGenerator.generate(tenant);
    }
}

@Service
@RequiredArgsConstructor
public class ReportCacheService {

    private final ReportCacheRepository reportCacheRepository;

    @Transactional  // read-write, REQUIRED
    public void cacheReport(TenantId tenantId, TenantReport report) {
        reportCacheRepository.save(ReportCache.of(tenantId, report));
    }
}

Reads use readOnly = true for the performance benefit. Writes use a standard read-write transaction. The caller orchestrates both:

TenantReport report = reportService.generateReport(tenantId);
reportCacheService.cacheReport(tenantId, report);

Isolation Levels

@Transactional(isolation = Isolation.REPEATABLE_READ) maps directly to:

Connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ)

The mapping:

  • Isolation.DEFAULT uses the database default (usually READ_COMMITTED)
  • Isolation.READ_UNCOMMITTED maps to Connection.TRANSACTION_READ_UNCOMMITTED
  • Isolation.READ_COMMITTED maps to Connection.TRANSACTION_READ_COMMITTED
  • Isolation.REPEATABLE_READ maps to Connection.TRANSACTION_REPEATABLE_READ
  • Isolation.SERIALIZABLE maps to Connection.TRANSACTION_SERIALIZABLE

The isolation level is set on the Connection when the transaction begins. It is reset to the default when the connection is returned to the pool. In a REQUIRES_NEW scenario, the inner transaction can have a different isolation level than the outer one because it uses a different Connection.

One pitfall: if you set isolation = Isolation.SERIALIZABLE on a REQUIRED method and a transaction already exists with READ_COMMITTED, Spring does not change the isolation level. The existing transaction’s isolation level wins. You get no error, no warning. The isolation level attribute is silently ignored when joining an existing transaction. This is documented but rarely read.

To enforce isolation, use REQUIRES_NEW so the method always gets its own Connection with the specified isolation level. Or use MANDATORY and document that callers must start a transaction with the correct isolation.

Summary Table

PropagationExisting Tx?BehaviorConnections
REQUIREDYesJoin1
REQUIREDNoCreate1
REQUIRES_NEWYesSuspend + Create2
REQUIRES_NEWNoCreate1
NESTEDYesSavepoint1
NESTEDNoCreate (like REQUIRED)1
SUPPORTSYesJoin1
SUPPORTSNoNon-transactional1 (auto-commit)
NOT_SUPPORTEDYesSuspend2
NOT_SUPPORTEDNoNon-transactional1 (auto-commit)
MANDATORYYesJoin1
MANDATORYNoThrow exception0
NEVERYesThrow exception0
NEVERNoNon-transactional1 (auto-commit)

The connection count column is the key. Size your connection pool based on the maximum concurrent connections your propagation patterns require, not the maximum concurrent requests.