Back to All Posts
Spring Framework

Spring Transactions Part 2: Mastering @Transactional Parameters

March 4, 2026
9 min read
Abid Hasan
Spring Transactions Part 2: Mastering @Transactional Parameters

This is Part 2 of a 4-part series on Spring Transactions:


In Part 1, we explored how Spring transactions work internally—the AOP proxies, transaction interceptors, and the lifecycle of a transaction. Now it's time to master the configuration options that give you fine-grained control over transaction behavior.

The @Transactional annotation accepts several parameters that control how transactions behave. Understanding each parameter is crucial for building robust applications.


Quick Recap: Part 1 Key Points

Before diving into parameters, let's recall what we learned:

  • Spring uses AOP proxies to intercept @Transactional method calls
  • The TransactionInterceptor manages the transaction lifecycle
  • PlatformTransactionManager abstracts different transaction APIs (JDBC, JPA, JTA)
  • Transactions automatically commit on success and rollback on exceptions

Now, let's configure this behavior!


@Transactional Parameters Overview

Here's a quick overview of all available parameters:

@Transactional( propagation = Propagation.REQUIRED, // Transaction propagation behavior isolation = Isolation.DEFAULT, // Isolation level timeout = 30, // Timeout in seconds readOnly = false, // Read-only hint rollbackFor = Exception.class, // Exceptions that trigger rollback noRollbackFor = ValidationException.class // Exceptions that don't trigger rollback ) public void yourMethod() { }

Let's explore each parameter in detail.


1. Propagation Behaviors

Default: Propagation.REQUIRED

Propagation behaviors define what happens when a @Transactional method calls another @Transactional method. This is one of Spring's most powerful yet misunderstood features.

REQUIRED (Default)

@Transactional(propagation = Propagation.REQUIRED)
  • If a transaction exists: Join it
  • If no transaction exists: Create a new one

Use case: Most common scenario—group operations within the same transaction.

@Service public class OrderService { @Transactional(propagation = Propagation.REQUIRED) public void createOrder(Order order) { orderRepository.save(order); inventoryService.updateStock(order); // Joins the same transaction paymentService.processPayment(order); // Joins the same transaction } }

REQUIRES_NEW

@Transactional(propagation = Propagation.REQUIRES_NEW)
  • Always: Creates a new transaction
  • If a transaction exists: Suspends the existing transaction

Use case: Audit logging that should commit even if the main transaction rolls back:

@Transactional public void transferMoney(Account from, Account to, BigDecimal amount) { // Main transaction accountService.debit(from, amount); accountService.credit(to, amount); // This ALWAYS commits, even if transfer fails auditLogService.logTransfer(from, to, amount); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void logTransfer(Account from, Account to, BigDecimal amount) { auditRepository.save(new AuditLog(from, to, amount)); }

NESTED

@Transactional(propagation = Propagation.NESTED)
  • If a transaction exists: Creates a nested transaction (savepoint)
  • If no transaction exists: Creates a new one

Use case: Partial rollback capability—nested transaction can roll back without affecting the outer transaction:

@Transactional public void processBatch(List<Item> items) { for (Item item : items) { try { // Each item processes in a nested transaction // If one fails, only that item rolls back processItem(item); } catch (Exception e) { // Log and continue with next item logger.error("Failed to process item", e); } } } @Transactional(propagation = Propagation.NESTED) public void processItem(Item item) { // ... business logic }

Database Support: Not all databases support nested transactions. JDBC 3.0 savepoints are required. Check your database documentation.

SUPPORTS

@Transactional(propagation = Propagation.SUPPORTS)
  • If a transaction exists: Join it
  • If no transaction exists: Execute non-transactionally

Use case: Read operations that can benefit from a transaction but don't require one:

@Transactional(propagation = Propagation.SUPPORTS, readOnly = true) public List<Account> getActiveAccounts() { return accountRepository.findByActiveTrue(); }

NOT_SUPPORTED

@Transactional(propagation = Propagation.NOT_SUPPORTED)
  • Always: Execute non-transactionally
  • If a transaction exists: Suspends it

Use case: Operations that should never be part of a transaction, like reporting queries or non-transactional data access:

@Transactional(propagation = Propagation.NOT_SUPPORTED) public Report generateComplexReport() { // Executes without a transaction, even if called from a transactional method return reportRepository.runComplexQuery(); }

MANDATORY

@Transactional(propagation = Propagation.MANDATORY)
  • If a transaction exists: Join it
  • If no transaction exists: Throw an exception

Use case: Operations that MUST run within a transaction, enforced by the framework:

@Transactional(propagation = Propagation.MANDATORY) public void performCriticalOperation(Data data) { // Will throw IllegalTransactionStateException if no transaction exists criticalDataRepository.save(data); }

NEVER

@Transactional(propagation = Propagation.NEVER)
  • If a transaction exists: Throw an exception
  • If no transaction exists: Execute non-transactionally

Use case: Operations that should never be called from within a transaction:

@Transactional(propagation = Propagation.NEVER) public void exportDataToFile(File file) { // Will throw IllegalTransactionStateException if called within a transaction dataExporter.export(file); }

Propagation Decision Tree


2. Isolation Levels

Default: Isolation.DEFAULT (uses the database's default isolation level)

Isolation levels control how concurrent transactions interact with each other. They determine what changes made by other transactions are visible within your transaction.

The Isolation Levels

DEFAULT

Uses the database's default isolation level. For most databases, this is READ_COMMITTED.

READ_UNCOMMITTED

The lowest isolation level. Transactions can see uncommitted changes from other transactions.

Risks: Dirty reads, non-repeatable reads, phantom reads Use case: Rarely used—only when absolute performance is critical and data accuracy isn't critical

READ_COMMITTED

A transaction can only see committed changes from other transactions.

Risks: Non-repeatable reads, phantom reads Use case: Good default for most applications

REPEATABLE_READ

Guarantees that if you read a row multiple times, you'll get the same values.

Risks: Phantom reads (new rows appearing) Use case: When you need consistency within a transaction for data you've already read

SERIALIZABLE

The highest isolation level. Transactions are completely isolated from each other.

Risks: None (fully ACID compliant) Use case: Critical operations where absolute consistency is required (e.g., financial transactions)

Isolation Level Comparison

Practical Examples

// High consistency for financial operations @Transactional(isolation = Isolation.SERIALIZABLE) public void transferCriticalFunds(Account from, Account to, BigDecimal amount) { // This transaction has full isolation accountRepository.debit(from, amount); accountRepository.credit(to, amount); } // Standard read operations @Transactional(isolation = Isolation.READ_COMMITTED, readOnly = true) public List<Account> getAccounts() { return accountRepository.findAll(); }

3. Timeout

Default: Uses the default timeout of the underlying transaction system

Defines the time (in seconds) before the transaction is automatically rolled back. Useful for preventing long-running transactions from holding database locks.

@Transactional(timeout = 5) // 5 seconds max public void processTimeSensitiveOperation(Data data) { // Will roll back after 5 seconds if not completed }

Timeout Configuration Strategies

You can configure timeouts at multiple levels:

// Method-level timeout @Transactional(timeout = 5) public void processPayment(Payment payment) { } // Or use default timeout in application.yml // spring: // transaction: // default-timeout: 10

Choosing a Timeout: Set timeout based on your operation's expected duration plus a safety margin. Monitor production to identify slow transactions.


4. ReadOnly

Default: false

A hint to the database that the transaction will only read data. This can enable optimizations:

@Transactional(readOnly = true) public List<Account> getAllAccounts() { return accountRepository.findAll(); }

Benefits of ReadOnly Transactions

  1. Performance: Database skips dirty checks and can optimize queries
  2. Connection Pooling: Some connection pools route read-only transactions to read replicas
  3. Intent: Clearly signals that the method doesn't modify data

Performance Tip: Always mark read-only operations with readOnly = true. This allows the database to skip dirty checks and can significantly improve query performance.


5. Rollback Rules

Default: Rolls back on RuntimeException, doesn't roll back on checked exceptions

Specify which exceptions should trigger a rollback:

// Rollback on custom checked exception @Transactional(rollbackFor = InsufficientFundsException.class) public void withdraw(Account account, BigDecimal amount) throws InsufficientFundsException { // ... } // Don't rollback even on runtime exception @Transactional(noRollbackFor = DataValidationException.class) public void processData(Data data) { // ... } // Multiple exceptions @Transactional( rollbackFor = { PaymentFailedException.class, InventoryUnavailableException.class }, noRollbackFor = { ValidationException.class } ) public Order createOrder(OrderRequest request) { // ... }

Rollback Decision Flow


Putting It All Together

Here's a comprehensive example using multiple parameters:

@Service @RequiredArgsConstructor public class PaymentService { private final PaymentRepository paymentRepository; private final AuditService auditService; @Transactional( propagation = Propagation.REQUIRED, isolation = Isolation.READ_COMMITTED, timeout = 10, readOnly = false, rollbackFor = { PaymentFailedException.class, InsufficientFundsException.class }, noRollbackFor = { ValidationException.class } ) public PaymentResult processPayment(PaymentRequest request) { // Validation exception won't roll back if (!request.isValid()) { throw new ValidationException("Invalid payment request"); } Payment payment = paymentRepository.save(new Payment(request)); // Audit log in separate transaction (always commits) auditService.logPaymentAttempt(payment); return new PaymentResult(payment.getId(), PaymentStatus.PROCESSED); } }

What's Next?

Now that you've mastered all the @Transactional parameters, it's time to learn about the common pitfalls that even experienced developers fall into.

In Part 3, we'll explore:

  • Why @Transactional doesn't work on private methods
  • The self-invocation proxy problem and solutions
  • Exception handling mistakes
  • Why you shouldn't mix programmatic and declarative transactions
  • Lazy loading exceptions and how to avoid them
  • Real-world before/after code examples

Continue to Part 3: Common Pitfalls and Best Practices →


Key Takeaways from Part 2:

  • Propagation controls how transactions interact (7 behaviors available)
  • Isolation controls concurrent transaction visibility (5 levels available)
  • Timeout prevents long-running transactions from holding locks
  • ReadOnly enables database optimizations for read operations
  • Rollback rules give fine-grained control over exception handling
  • Use REQUIRED (propagation) and READ_COMMITTED (isolation) as defaults, adjust based on needs

Tags

#Spring#Transactions#Java#Backend#Database