Home Java Spring Transaction Management: Propagation and Isolation Explained
Advanced 4 min · July 14, 2026

Spring Transaction Management: Propagation and Isolation Explained

Learn Spring transaction management with propagation and isolation levels.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Production
production tested
July 14, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic understanding of Spring Boot and dependency injection
  • Familiarity with JPA or JDBC for database operations
  • Java 17+ and Maven/Gradle installed
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

• Spring transaction management uses @Transactional to control database transactions declaratively • Propagation defines how transactions behave across method calls (e.g., REQUIRED, REQUIRES_NEW) • Isolation levels control data visibility between concurrent transactions (e.g., READ_COMMITTED, SERIALIZABLE) • Default propagation is REQUIRED, default isolation is READ_COMMITTED in most databases • Rollback rules are critical: unchecked exceptions trigger rollback by default, checked exceptions do not

✦ Definition~90s read
What is Spring Transaction Management?

Spring transaction management is a framework that lets you define how database transactions behave in your service layer using annotations, controlling propagation, isolation, and rollback behavior declaratively.

Think of a transaction like a bank transfer.
Plain-English First

Think of a transaction like a bank transfer. Propagation is like deciding whether to start a new transfer or join an existing one. Isolation is like choosing how much privacy you need: do you let others see your pending balance or wait until it’s finalized? Spring manages this automatically with @Transactional annotations.

Transaction management is the backbone of any reliable enterprise application. Without proper transaction control, you risk data corruption, inconsistent states, and hard-to-debug production issues. In Spring, the @Transactional annotation provides a declarative way to manage transactions, but it’s not magic. You need to understand propagation and isolation to avoid subtle bugs that can bring down a payment system or a SaaS billing pipeline.

Spring’s transaction abstraction works with both local (JDBC, JPA) and global (JTA) transactions. The key is that it uses AOP proxies to wrap your method calls. This means internal method calls within the same class bypass the proxy and the transaction settings are ignored. This is a common pitfall for beginners.

In this article, we’ll cover propagation behaviors, isolation levels, rollback rules, and real-world scenarios. You’ll learn how to configure transactions in Spring Boot 3.x with practical code examples. By the end, you’ll be able to design transactional boundaries that prevent phantom reads, dirty reads, and non-repeatable reads in your applications.

We’ll also share a production incident that cost a fintech company $50k because of misconfigured propagation. Let’s dive in.

What is Spring Transaction Management?

Spring transaction management provides a consistent programming model for handling database transactions across different APIs like JDBC, JPA, Hibernate, and JTA. The core is the @Transactional annotation, which you place on service methods or classes. When Spring encounters this annotation, it creates a transaction proxy around your bean. The proxy starts a transaction before the method executes and commits or rolls back based on the outcome.

Spring supports two modes: declarative (using annotations) and programmatic (using TransactionTemplate). Declarative is preferred because it keeps transaction logic out of your business code. Under the hood, Spring uses AOP (Aspect-Oriented Programming) to intercept method calls. This is why self-invocation (calling a @Transactional method from within the same class) doesn’t work — the proxy is bypassed.

Here’s a simple example of a service that manages user registration with a transaction:

UserService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    @Transactional
    public User registerUser(String email, String name) {
        if (userRepository.existsByEmail(email)) {
            throw new IllegalArgumentException("Email already exists");
        }
        User user = new User(email, name);
        return userRepository.save(user);
    }
}
Output
Transaction starts when registerUser is called. If email exists, exception thrown and transaction rolls back. Otherwise, user saved and transaction commits.
⚠ Self-Invocation Trap
📊 Production Insight
In high-traffic systems, always set a timeout on transactions to prevent long-running operations from holding database connections. Use @Transactional(timeout = 5) in seconds.
🎯 Key Takeaway
Declarative transaction management with @Transactional is the standard approach. Understand that it relies on AOP proxies, so self-invocation is a common pitfall.

What the Official Docs Won’t Tell You

The official Spring documentation covers propagation and isolation levels, but it glosses over the real-world pain points. Here’s what they don’t emphasize enough:

First, the default rollback policy only works for unchecked exceptions (RuntimeException and Error). Checked exceptions (like Exception) do NOT trigger a rollback by default. This is a massive footgun. If your service method throws a checked exception, the transaction will commit even if the operation failed. You must explicitly configure rollbackFor for checked exceptions.

Second, isolation levels are database-dependent. MySQL’s REPEATABLE_READ is different from PostgreSQL’s. Spring’s default isolation level is DEFAULT, which uses the database’s default. For MySQL, that’s REPEATABLE_READ; for PostgreSQL, it’s READ_COMMITTED. Never assume consistency across databases.

Third, propagation REQUIRES_NEW is a double-edged sword. It suspends the current transaction and starts a new one. If the new transaction commits but the outer one rolls back, you have partial commits. This is useful for logging or audit trails, but dangerous for business-critical operations.

Here’s an example of configuring rollback for checked exceptions:

PaymentService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class PaymentService {

    @Transactional(rollbackFor = PaymentException.class)
    public void processPayment(Payment payment) throws PaymentException {
        // validate payment
        if (payment.getAmount() <= 0) {
            throw new PaymentException("Invalid amount");
        }
        // process payment logic
        paymentRepository.save(payment);
    }
}
Output
If PaymentException (checked exception) is thrown, transaction rolls back. Without rollbackFor, the transaction would commit despite the error.
🔥Checked Exception Gotcha
📊 Production Insight
In a SaaS billing system, we had a bug where a checked exception from a third-party API caused partial commits. Adding rollbackFor fixed it, but we also added a compensating transaction pattern to handle already-committed side effects.
🎯 Key Takeaway
Always define rollbackFor for checked exceptions. Understand your database’s default isolation level. Use REQUIRES_NEW only when you explicitly need independent transaction boundaries.

Understanding Transaction Propagation

Propagation defines how transactions behave when a transactional method calls another transactional method. Spring supports seven propagation levels, but the most commonly used are REQUIRED, REQUIRES_NEW, NESTED, MANDATORY, NEVER, SUPPORTS, and NOT_SUPPORTED.

REQUIRED (default): If a transaction exists, join it; otherwise, create a new one. This is the most common choice for service methods that should participate in the caller’s transaction.

REQUIRES_NEW: Always create a new transaction, suspending the current one if it exists. Use this for operations that must commit independently, like audit logging.

NESTED: Uses a savepoint within the existing transaction. If the nested transaction rolls back, the outer transaction can continue or roll back to the savepoint. This is database-dependent (works with JDBC savepoints, but not always with JPA).

OrderService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Service
public class OrderService {

    @Autowired
    private PaymentService paymentService;

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        // This calls a method with REQUIRES_NEW
        paymentService.processPayment(order.getPayment());
        // If payment fails, order is still saved because payment has its own transaction
    }
}

@Service
public class PaymentService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void processPayment(Payment payment) {
        paymentRepository.save(payment);
        if (payment.getAmount() > 10000) {
            throw new RuntimeException("Payment limit exceeded");
        }
    }
}
Output
If processPayment throws an exception, the payment transaction rolls back, but the order transaction commits. The order is saved without a payment, causing data inconsistency.
⚠ REQUIRES_NEW Danger
📊 Production Insight
In a real-time analytics pipeline, we used REQUIRES_NEW for event logging to ensure logs were persisted even if the main processing failed. This helped with debugging but required a separate cleanup job to handle orphaned logs.
🎯 Key Takeaway
REQUIRED is safe for most use cases. REQUIRES_NEW should be reserved for operations that must be atomic independently, like audit logs or idempotent operations.

Isolation Levels Explained

Isolation levels control how transactions interact with each other concurrently. They prevent three phenomena: dirty reads (reading uncommitted data), non-repeatable reads (reading different values in the same transaction), and phantom reads (seeing new rows inserted by another transaction).

Spring supports five isolation levels: DEFAULT (database default), READ_UNCOMMITTED (lowest, allows dirty reads), READ_COMMITTED (prevents dirty reads), REPEATABLE_READ (prevents dirty and non-repeatable reads), and SERIALIZABLE (highest, prevents all phenomena but hurts performance).

READ_COMMITTED is the most common choice for production systems because it balances consistency and performance. SERIALIZABLE is rarely used except for critical financial operations.

InventoryService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class InventoryService {

    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void deductStock(Long productId, int quantity) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new RuntimeException("Product not found"));
        if (product.getStock() < quantity) {
            throw new RuntimeException("Insufficient stock");
        }
        product.setStock(product.getStock() - quantity);
        productRepository.save(product);
    }
}
Output
With REPEATABLE_READ, if another transaction reads the same product within its transaction, it will see the same stock value. This prevents overselling in concurrent scenarios.
🔥Isolation vs Performance
📊 Production Insight
In a high-volume e-commerce system, we used REPEATABLE_READ for stock deduction to prevent overselling. However, we also added pessimistic locking with @Lock(PESSIMISTIC_WRITE) to avoid race conditions at high concurrency.
🎯 Key Takeaway
Choose isolation levels based on your concurrency requirements. READ_COMMITTED is the default for PostgreSQL and works for most cases. REPEATABLE_READ is useful for inventory or financial systems.

Configuring Rollback Behavior

Rollback behavior determines which exceptions cause the transaction to roll back. By default, Spring rolls back on unchecked exceptions (RuntimeException and Error) and commits on checked exceptions. You can customize this using rollbackFor, noRollbackFor, rollbackForClassName, and noRollbackForClassName.

For example, you might want to roll back on a specific checked exception like InsufficientFundsException, but not on a validation exception like ValidationException that should be handled gracefully.

You can also configure rollback rules at the class level using @Transactional on the class, which applies to all public methods. Method-level annotations override class-level ones.

AccountService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
@Transactional
public class AccountService {

    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        Account from = accountRepository.findById(fromId)
            .orElseThrow(() -> new AccountNotFoundException("From account not found"));
        Account to = accountRepository.findById(toId)
            .orElseThrow(() -> new AccountNotFoundException("To account not found"));
        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
        accountRepository.save(from);
        accountRepository.save(to);
    }

    @Transactional(noRollbackFor = {ValidationException.class})
    public void validateAndTransfer(...) {
        // validation might throw ValidationException, which won't rollback
        // but AccountNotFoundException will rollback
    }
}
Output
By default, AccountNotFoundException (unchecked) triggers rollback. ValidationException (checked) with noRollbackFor commits the transaction even if thrown.
⚠ Rollback Rules Are Inherited
📊 Production Insight
In a payment gateway, we had a custom BusinessException hierarchy. We configured rollbackFor on the base exception class and noRollbackFor on specific subclasses like RetryableException, which allowed us to retry without losing the transaction.
🎯 Key Takeaway
Use rollbackFor and noRollbackFor to precisely control transaction boundaries. Document why certain exceptions don’t cause rollbacks to avoid confusion.

Transaction Propagation in Practice

Let’s look at a real-world scenario: a SaaS billing system that handles subscription upgrades. When a user upgrades, you need to create a new invoice, update the subscription, and charge the user. If any step fails, everything should roll back.

But you also want to log the upgrade attempt in an audit table, even if the upgrade fails. This is a perfect use case for REQUIRES_NEW on the audit logging method.

SubscriptionService.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Service
public class SubscriptionService {

    @Autowired
    private AuditService auditService;

    @Transactional
    public void upgradeSubscription(Long userId, String newPlan) {
        try {
            Subscription sub = subscriptionRepository.findByUserId(userId);
            sub.setPlan(newPlan);
            sub.setPrice(calculatePrice(newPlan));
            subscriptionRepository.save(sub);
            // This will commit even if outer transaction rolls back
            auditService.logUpgradeAttempt(userId, newPlan, "SUCCESS");
        } catch (Exception e) {
            auditService.logUpgradeAttempt(userId, newPlan, "FAILED: " + e.getMessage());
            throw e;
        }
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logUpgradeAttempt(Long userId, String plan, String status) {
        AuditLog log = new AuditLog(userId, plan, status);
        auditRepository.save(log);
    }
}
Output
Even if upgradeSubscription fails and rolls back, the audit log is committed because it uses REQUIRES_NEW. This provides a reliable audit trail.
🔥Audit Logging Pattern
📊 Production Insight
In production, we also added a timeout to the audit logging method to prevent it from blocking the main transaction. @Transactional(propagation = Propagation.REQUIRES_NEW, timeout = 2)
🎯 Key Takeaway
Use REQUIRES_NEW for operations that must be persistent regardless of the outer transaction outcome. Always handle exceptions in the outer method to log failures.

Testing Transactional Behavior

Testing transactions is tricky because you need a real database to verify rollback and isolation behavior. In-memory databases like H2 are useful for unit tests, but they don’t always behave like production databases (e.g., MySQL’s REPEATABLE_READ vs H2’s implementation).

For integration tests, use Testcontainers to spin up a real database container. This ensures your transaction configuration works as expected in production.

Spring provides @Transactional on test methods to roll back after each test. This is great for isolation but can mask issues where transactions are not properly configured.

SubscriptionServiceTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@SpringBootTest
@Testcontainers
class SubscriptionServiceTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");

    @Autowired
    private SubscriptionService subscriptionService;

    @Autowired
    private AuditRepository auditRepository;

    @Test
    void shouldRollbackOnFailure() {
        assertThrows(RuntimeException.class, () -> {
            subscriptionService.upgradeSubscription(1L, "invalid-plan");
        });
        // Assert that subscription was rolled back
        assertEquals(0, subscriptionRepository.count());
        // Assert that audit log was saved despite rollback
        assertEquals(1, auditRepository.count());
    }
}
Output
The test verifies that the subscription is rolled back but the audit log is persisted, confirming REQUIRES_NEW behavior.
⚠ Testcontainers in CI/CD
📊 Production Insight
We once had a bug where H2 tests passed but MySQL production failed because H2 didn’t enforce the same isolation level. Switching to Testcontainers caught the issue immediately.
🎯 Key Takeaway
Always test transaction behavior with a real database. Use Testcontainers for integration tests. Avoid relying solely on H2 for transaction testing.

Debugging Transaction Issues in Production

Transaction issues often manifest as data inconsistencies, deadlocks, or performance degradation. Common symptoms include:

  • Partial commits: Some data saved, some not (caused by wrong propagation)
  • Deadlocks: Two transactions waiting for each other (caused by high isolation or long transactions)
  • Stale data: Reading old data (caused by wrong isolation level)

To debug, enable Spring’s transaction logging by adding logging.level.org.springframework.transaction=DEBUG in application.properties. This shows when transactions start, commit, or roll back.

Use database monitoring tools like MySQL’s SHOW ENGINE INNODB STATUS or PostgreSQL’s pg_stat_activity to see active transactions and locks.

application.propertiesJAVA
1
2
3
4
5
6
7
# Enable transaction logging
logging.level.org.springframework.transaction=DEBUG
logging.level.org.springframework.orm.jpa=DEBUG

# Enable SQL logging
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
Output
Logs will show: "Creating new transaction with name [com.example.Service.method]" and "Committing JPA transaction on EntityManager" or "Rolling back JPA transaction on EntityManager"
🔥Transaction Logging in Production
📊 Production Insight
In a real-time analytics system, we used Spring’s TransactionSynchronizationManager to register custom synchronization callbacks. This helped us track transaction boundaries in distributed tracing.
🎯 Key Takeaway
Enable transaction logging for debugging. Use database-level monitoring to detect deadlocks and long-running transactions. Always have a rollback plan.
● Production incidentPOST-MORTEMseverity: high

The $50k Transaction: How Misconfigured Propagation Caused a Payment Nightmare

Symptom
Users were charged multiple times for a single purchase, but the order creation failed silently. Duplicate payments appeared in the ledger.
Assumption
The team assumed that all database operations within a service method would roll back together if any exception occurred.
Root cause
A nested service method used REQUIRES_NEW propagation, which started a new transaction that committed independently even when the outer transaction rolled back. The payment was processed in the inner transaction, but the order creation in the outer transaction failed.
Fix
Changed propagation to REQUIRED (default) so that the payment and order creation shared the same transaction. If either fails, both roll back atomically.
Key lesson
  • Always verify propagation behavior for nested transactional methods
  • Use integration tests with real databases to catch transaction boundary issues
  • REQUIRES_NEW should be used sparingly and only when you truly need independent commits
Production debug guideStep-by-step guide to identify and fix transaction problems3 entries
Symptom · 01
Partial data saves: some records saved, some not
Fix
Check propagation levels. Look for REQUIRES_NEW in nested calls. Enable transaction logging to see commit/rollback boundaries.
Symptom · 02
Deadlocks or slow queries
Fix
Check isolation levels. SERIALIZABLE can cause deadlocks. Reduce to READ_COMMITTED if possible. Use database monitoring tools like SHOW ENGINE INNODB STATUS.
Symptom · 03
Stale data reads
Fix
Check isolation level. If using READ_UNCOMMITTED, dirty reads occur. Upgrade to READ_COMMITTED or REPEATABLE_READ. Verify that transactions are not too long.
★ Quick Transaction Debug Cheat SheetImmediate actions for common transaction symptoms
Transaction not rolling back on exception
Immediate action
Check if exception is checked or unchecked
Commands
logging.level.org.springframework.transaction=DEBUG
Check @Transactional(rollbackFor = ...)
Fix now
Add rollbackFor attribute for checked exceptions
Partial commits in nested calls+
Immediate action
Identify methods with REQUIRES_NEW
Commands
grep -r "REQUIRES_NEW" src/main/java/
Check if independent commit is intentional
Fix now
Change to REQUIRED or handle compensation logic
Deadlock in production+
Immediate action
Kill the blocking transaction
Commands
SHOW ENGINE INNODB STATUS\G
KILL <transaction_id>
Fix now
Reduce isolation level or add retry logic with exponential backoff
PropagationBehaviorUse Case
REQUIREDJoins existing transaction or creates new oneDefault for most service methods
REQUIRES_NEWAlways creates new transaction, suspends currentAudit logging, independent operations
NESTEDUses savepoint within existing transactionPartial rollback within a transaction
MANDATORYMust run within an existing transaction, throws exception if noneEnforcing transactional context
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
UserService.java@ServiceWhat is Spring Transaction Management?
PaymentService.java@ServiceWhat the Official Docs Won’t Tell You
OrderService.java@ServiceUnderstanding Transaction Propagation
InventoryService.java@ServiceIsolation Levels Explained
AccountService.java@ServiceConfiguring Rollback Behavior
SubscriptionService.java@ServiceTransaction Propagation in Practice
SubscriptionServiceTest.java@SpringBootTestTesting Transactional Behavior
application.propertieslogging.level.org.springframework.transaction=DEBUGDebugging Transaction Issues in Production

Key takeaways

1
Understand the seven propagation levels and use REQUIRED as the default. REQUIRES_NEW is for independent operations like audit logging.
2
Always configure rollbackFor for checked exceptions to avoid accidental commits on failure.
3
Choose isolation levels based on concurrency needs
READ_COMMITTED for most apps, REPEATABLE_READ for inventory/financial systems.
4
Test transactions with real databases using Testcontainers to catch database-specific behavior.
5
Enable transaction logging in production temporarily to debug issues, but monitor performance impact.

Common mistakes to avoid

3 patterns
×

Forgetting to configure rollbackFor for checked exceptions

×

Using REQUIRES_NEW without understanding its independent commit behavior

×

Calling @Transactional methods from within the same class

INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between REQUIRED and REQUIRES_NEW propagation. Wh...
Q02JUNIOR
What are the three phenomena that isolation levels prevent?
Q03SENIOR
How would you debug a transaction that is not rolling back on a checked ...
Q01 of 03SENIOR

Explain the difference between REQUIRED and REQUIRES_NEW propagation. When would you use each?

ANSWER
REQUIRED (default) joins an existing transaction or creates a new one. It ensures all operations share the same transaction boundary. REQUIRES_NEW always creates a new transaction, suspending the current one. Use REQUIRED for most business logic where atomicity is needed. Use REQUIRES_NEW for operations that must commit independently, like audit logging or sending notifications, where failure shouldn’t affect the main transaction.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the default propagation level in Spring?
02
How do I make a checked exception trigger a rollback?
03
What is the difference between REQUIRES_NEW and NESTED propagation?
04
Why does @Transactional not work when calling a method from within the same class?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Everything here is grounded in real deployments.

Follow
Verified
production tested
July 14, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Boot. Mark it forged?

4 min read · try the examples if you haven't

Previous
Database Migrations with Flyway and Liquibase in Spring Boot
25 / 121 · Spring Boot
Next
Spring WebFlux: Reactive Programming with Spring Boot 3