← Back to Tutorials

@Transactional Demystified: Every Attribute With Working Tests

Every @Transactional attribute explained with real working code: propagation, rollbackFor, readOnly, timeout, and isolation, each proved by a failing test.

@Transactional looks simple until something goes wrong. A checked exception escapes without rolling back. An inner service method quietly ignores the outer transaction. A read-heavy endpoint hammers dirty-check overhead that was never needed. The fix for each of these is in the annotation's attributes, and those are the ones most tutorials skip over.

This tutorial covers every attribute with working code and tests that prove the behavior. All code is in the companion repo: github.com/umur/spring-transactional. Run mvn verify and all tests pass.

How @Transactional Works

Spring wraps your bean in a proxy at startup. When another bean calls a @Transactional method on your service, the call goes through the proxy, which starts the transaction, hands control to your method, then commits or rolls back when the method returns.

Two rules that trip everyone up before anything else:

  • Self-invocation bypasses the proxy. If method A calls method B in the same class, the @Transactional on B is ignored entirely. Spring never sees the call.
  • The annotation only works on Spring beans. Plain Java classes, static methods, and private methods get no transaction management regardless of the annotation.

With that out of the way, here is what each attribute actually does.

propagation

propagation controls what happens when a @Transactional method is called while a transaction is already active. There are seven values. The default is REQUIRED.

To see propagation in action you need two separate Spring beans. A single class calling its own methods bypasses the proxy and no propagation logic runs. The examples below use OuterService calling InnerService:

@Service
@RequiredArgsConstructor
public class OuterService {

    private final OrderRepository orderRepository;
    private final InnerService innerService;

    @Transactional
    public void requiresNewAndFail() {
        orderRepository.save(new Order("OUTER"));
        innerService.saveWithRequiresNew();
        throw new RuntimeException("outer fails");
    }
}

@Service
@RequiredArgsConstructor
public class InnerService {

    private final OrderRepository orderRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveWithRequiresNew() {
        orderRepository.save(new Order("INNER"));
    }
}

REQUIRED

Joins the existing transaction. If none exists, creates one. This is the right default for almost every service method. Both the outer and inner work succeed or fail together.

@Transactional(propagation = Propagation.REQUIRED)
public void saveWithRequired() {
    orderRepository.save(new Order("INNER"));
}
@Test
@DisplayName("REQUIRED: inner joins outer's transaction, both roll back on failure")
void required_innerJoinsOuterTransaction_bothRollBack() {
    assertThatThrownBy(() -> outerService.requiredAndFail())
            .isInstanceOf(RuntimeException.class);

    assertThat(orderRepository.findAll()).isEmpty();
}

REQUIRES_NEW

Always creates a fresh transaction, suspending the caller's. Whatever the inner method commits is committed permanently, even if the outer transaction rolls back afterward.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveWithRequiresNew() {
    orderRepository.save(new Order("INNER"));
}
@Test
@DisplayName("REQUIRES_NEW: inner commits in its own transaction even when outer fails")
void requiresNew_innerCommitsWhenOuterFails() {
    assertThatThrownBy(() -> outerService.requiresNewAndFail())
            .isInstanceOf(RuntimeException.class);

    var orders = orderRepository.findAll();
    assertThat(orders).hasSize(1);
    assertThat(orders.get(0).getStatus()).isEqualTo("INNER");
}

Use this for operations that must commit regardless of the caller: audit logs, notification records, event outbox entries.

MANDATORY

Requires an active transaction. Throws IllegalTransactionStateException if called without one. Use this on internal methods that should never be called outside a transactional context. Violations then fail loudly, not silently.

@Transactional(propagation = Propagation.MANDATORY)
public void saveWithMandatory() {
    orderRepository.save(new Order("INNER"));
}
@Test
@DisplayName("MANDATORY: throws when called without an active transaction")
void mandatory_throwsWithoutExistingTransaction() {
    assertThatThrownBy(() -> innerService.saveWithMandatory())
            .isInstanceOf(IllegalTransactionStateException.class);
}

NEVER

The opposite of MANDATORY. Throws IllegalTransactionStateException if called with an active transaction. Use this on long-running read operations or batch jobs that must not hold locks. Calling them from a transactional context is a programming error you want to catch early.

@Test
@DisplayName("NEVER: throws when called from within an active transaction")
void never_throwsWhenCalledInsideTransaction() {
    assertThatThrownBy(() -> outerService.callNever())
            .isInstanceOf(IllegalTransactionStateException.class);
}

NOT_SUPPORTED

Suspends the current transaction and runs without one. The inner method auto-commits each statement as it executes. When the outer transaction rolls back, the inner work is already committed and unaffected.

@Test
@DisplayName("NOT_SUPPORTED: inner runs outside the transaction and commits even when outer fails")
void notSupported_innerCommitsWhenOuterFails() {
    assertThatThrownBy(() -> outerService.notSupportedAndFail())
            .isInstanceOf(RuntimeException.class);

    var orders = orderRepository.findAll();
    assertThat(orders).hasSize(1);
    assertThat(orders.get(0).getStatus()).isEqualTo("INNER");
}

The outcome looks the same as REQUIRES_NEW, but without the overhead of creating a new transaction object. Use it when the operation simply does not need transactional guarantees.

NESTED

Runs within a JDBC savepoint inside the current transaction. If the inner method fails, it rolls back to the savepoint and the outer transaction is unaffected and can continue. If the outer transaction fails, everything rolls back including the savepoint.

@Transactional
public void nestedWithInnerFailureCaught() {
    orderRepository.save(new Order("OUTER"));
    try {
        innerService.saveWithNestedAndFail(); // throws after saving INNER
    } catch (RuntimeException ignored) {
        // savepoint rolled back, outer transaction continues
    }
}
@Test
@DisplayName("NESTED: inner rolls back to savepoint, outer commits successfully")
void nested_innerRollsBackToSavepoint_outerCommits() {
    outerService.nestedWithInnerFailureCaught();

    var orders = orderRepository.findAll();
    assertThat(orders).hasSize(1);
    assertThat(orders.get(0).getStatus()).isEqualTo("OUTER");
}

NESTED requires savepoint support from the JDBC driver. PostgreSQL supports it. H2 does too. Check your driver documentation if you are on Oracle or older MySQL.

SUPPORTS

Uses the caller's transaction if one exists. Runs non-transactionally otherwise. When called from within a transaction, it rolls back with the transaction. When called standalone, each statement auto-commits immediately.

@Transactional(propagation = Propagation.SUPPORTS)
public void saveWithSupports() {
    orderRepository.save(new Order("INNER"));
}

Quick reference

PropagationNo active transactionActive transaction exists
REQUIREDCreates newJoins existing
REQUIRES_NEWCreates newSuspends existing, creates new
MANDATORYThrowsJoins existing
NEVERRuns without transactionThrows
NOT_SUPPORTEDRuns without transactionSuspends existing, runs without
NESTEDCreates newRuns in savepoint
SUPPORTSRuns without transactionJoins existing

rollbackFor and noRollbackFor

By default, Spring only rolls back on RuntimeException and Error. Checked exceptions let the transaction commit. This is the source of one of the most common transaction bugs in Spring applications.

@Transactional
public void saveAndThrowChecked() throws IOException {
    orderRepository.save(new Order("PENDING"));
    throw new IOException("disk full");
}
@Test
@DisplayName("default: checked exception does NOT roll back the transaction")
void checkedExceptionDoesNotRollbackByDefault() {
    assertThatThrownBy(() -> orderService.saveAndThrowChecked())
            .isInstanceOf(IOException.class);

    assertThat(orderRepository.findAll()).hasSize(1); // the order was committed
}

Fix it with rollbackFor:

@Transactional(rollbackFor = Exception.class)
public void saveAndThrowCheckedWithRollback() throws IOException {
    orderRepository.save(new Order("PENDING"));
    throw new IOException("disk full");
}
@Test
@DisplayName("rollbackFor = Exception.class: checked exception rolls back the transaction")
void rollbackForCausesRollbackOnCheckedException() {
    assertThatThrownBy(() -> orderService.saveAndThrowCheckedWithRollback())
            .isInstanceOf(IOException.class);

    assertThat(orderRepository.findAll()).isEmpty(); // the order was NOT committed
}

noRollbackFor works the other way: it allows commit even for a RuntimeException:

@Transactional(noRollbackFor = IllegalArgumentException.class)
public void saveAndThrowRuntimeNoRollback() {
    orderRepository.save(new Order("PENDING"));
    throw new IllegalArgumentException("bad input");
}
@Test
@DisplayName("noRollbackFor: RuntimeException does NOT roll back when excluded")
void noRollbackForAllowsCommitOnRuntimeException() {
    assertThatThrownBy(() -> orderService.saveAndThrowRuntimeNoRollback())
            .isInstanceOf(IllegalArgumentException.class);

    assertThat(orderRepository.findAll()).hasSize(1); // committed despite RuntimeException
}

Each attribute also has a string variant (rollbackForClassName, noRollbackForClassName) that accepts class name strings instead of class literals. They exist for cases where you cannot take a compile-time dependency on an exception class. You will rarely need them.

readOnly

readOnly = true is a hint, not a hard constraint. It tells Hibernate and the database driver that this transaction will only read.

What Hibernate does with it: it disables automatic dirty checking. Normally at flush time, Hibernate compares every loaded entity against its original snapshot to detect changes. With readOnly = true, it skips that scan entirely. On a service method that loads hundreds of entities, this is a measurable throughput improvement.

@Transactional(readOnly = true)
public List<Order> findAll() {
    return orderRepository.findAll();
}

The database side: most drivers propagate the read-only flag to the connection, and the database can use it to skip locking overhead. PostgreSQL respects it.

What readOnly does not do: it does not prevent writes at the application level. Whether Hibernate enforces this depends on the version and configuration. If you need to prevent writes, do it in your business logic and do not rely on readOnly as a guard.

The rule is simple: put readOnly = true on every service method that only reads. It costs nothing and helps at scale.

timeout

timeout sets the maximum number of seconds a transaction may run. The default is -1, which means no limit.

@Transactional(timeout = 1)
public void slowOperation() throws InterruptedException {
    orderRepository.save(new Order("PENDING"));
    Thread.sleep(2000);
    orderRepository.count(); // triggers the timeout check
}

When the deadline passes and the next database call runs, Spring throws TransactionTimedOutException and rolls back the transaction.

@Test
@DisplayName("timeout = 1: throws TransactionException when transaction exceeds the deadline")
void timeout_throwsWhenDeadlineExceeded() {
    assertThatThrownBy(() -> orderService.slowOperation())
            .isInstanceOf(TransactionException.class);
}

A few things worth knowing:

  • The clock starts when the transaction begins, not when the first query runs.
  • The timeout is checked at the next database operation, not during Thread.sleep or CPU work. A method that sleeps for ten seconds but makes no DB calls afterward will not be interrupted.
  • Spring's timeout is applied on top of whatever the database's own statement timeout is. Either one can fire first.

isolation

isolation sets the transaction isolation level, which controls how the transaction sees data being written by other concurrent transactions.

There are four anomalies that isolation levels protect against:

  • Dirty read: Transaction A reads uncommitted data written by B. If B rolls back, A was working with phantom data that never existed.
  • Non-repeatable read: A reads the same row twice and gets different values because B committed a change in between.
  • Phantom read: A runs the same range query twice and gets different rows because B inserted or deleted rows in between.
LevelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTEDPossiblePossiblePossible
READ_COMMITTEDPreventedPossiblePossible
REPEATABLE_READPreventedPreventedPossible
SERIALIZABLEPreventedPreventedPrevented

PostgreSQL's default is READ_COMMITTED. Most production workloads never need to change it. Isolation.DEFAULT, which is what you get when you do not set the attribute, tells Spring to use whatever the database's default is.

// Reads only committed data. Prevents dirty reads. PostgreSQL's default.
@Transactional(isolation = Isolation.READ_COMMITTED)
public List<Order> findAllReadCommitted() {
    return orderRepository.findAll();
}

// Re-reading the same row within a transaction always returns the same result.
// Prevents dirty reads and non-repeatable reads.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public List<Order> findAllRepeatableRead() {
    return orderRepository.findAll();
}

// Full isolation. Prevents dirty reads, non-repeatable reads, and phantom reads.
// Highest consistency, highest contention.
@Transactional(isolation = Isolation.SERIALIZABLE)
public List<Order> findAllSerializable() {
    return orderRepository.findAll();
}
@Test
@DisplayName("isolation = READ_COMMITTED: reads only committed data")
void isolation_readCommitted_readsCommittedData() {
    orderRepository.save(new Order("COMMITTED"));

    var orders = orderService.findAllReadCommitted();

    assertThat(orders).hasSize(1);
    assertThat(orders.get(0).getStatus()).isEqualTo("COMMITTED");
}

@Test
@DisplayName("isolation = REPEATABLE_READ: consistent reads within the transaction")
void isolation_repeatableRead_readsConsistently() {
    orderRepository.save(new Order("STABLE"));

    var orders = orderService.findAllRepeatableRead();

    assertThat(orders).hasSize(1);
    assertThat(orders.get(0).getStatus()).isEqualTo("STABLE");
}

@Test
@DisplayName("isolation = SERIALIZABLE: fully isolated, highest consistency guarantee")
void isolation_serializable_fullyIsolated() {
    orderRepository.save(new Order("ISOLATED"));

    var orders = orderService.findAllSerializable();

    assertThat(orders).hasSize(1);
    assertThat(orders.get(0).getStatus()).isEqualTo("ISOLATED");
}

One practical note: PostgreSQL does not support READ_UNCOMMITTED. It silently falls back to READ_COMMITTED. On MySQL it is supported. If you need true dirty reads, check your database documentation.

transactionManager

When an application has multiple data sources, there are multiple transaction managers. By default, @Transactional uses the primary one. Use transactionManager (or its alias value) to specify which one.

@Transactional("primaryTransactionManager")
public void writeToMainDatabase() {
    // ...
}

@Transactional("analyticsTransactionManager")
public void writeToAnalyticsDatabase() {
    // ...
}

If you are repeating the transaction manager name across many methods, create a custom annotation to avoid the string duplication:

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Transactional("analyticsTransactionManager")
public @interface AnalyticsTransactional {
}

Single-datasource applications do not need this attribute.

Putting it together

Most of the time @Transactional with no attributes does the right thing. The defaults (REQUIRED propagation, rollback on runtime exceptions, no timeout, no isolation override) are sensible for standard CRUD services.

But the attributes matter when the defaults fall short. Reach for REQUIRES_NEW when audit logs must survive a failed business operation. Reach for rollbackFor = Exception.class when your service layer throws checked exceptions. Use readOnly = true on every query-only method, timeout on operations that must not hold locks indefinitely, and MANDATORY on internal methods that should only be called from within a transaction.

The tests in the companion repo cover all of these. Clone it, run mvn verify, and each test name describes exactly what it asserts.

Share
X LinkedIn HN
UI

Umur Inan

Principal Software Engineer

Backend engineer focused on JVM systems, distributed architecture, and the failure modes that only show up in production. I write about what I learn building and breaking things at scale.