@Transactional & transaction management
Mid / SeniorHow @Transactional works, when changes actually reach the database, rollback rules, propagation, readOnly, and the classic pitfalls.
Created 5 October 2026 · Updated 23 October 2026
12 concepts · detail hidden
01How @Transactional works under the hood
#c-how-transactional-works-under-the-hood- The bean is wrapped in a proxy (see Proxies & AOP) whose interceptor is
TransactionInterceptor. - On call, the interceptor reads the method’s transaction attributes (propagation,
isolation, readOnly, timeout, rollback rules) and asks the
PlatformTransactionManagerto begin or join a transaction —JpaTransactionManagerfor JPA/Hibernate,DataSourceTransactionManagerfor plain JDBC. - The transaction manager takes a connection from the pool, calls
setAutoCommit(false), and binds the connection /EntityManagerto the current thread viaTransactionSynchronizationManager(aThreadLocal). That’s how a repository deep in the call stack finds “the current transaction” without it being passed around. - Your method runs.
- On normal return → commit (for JPA: flush the persistence context, then commit the JDBC connection). On an exception matching the rollback rules → rollback. The exception is then rethrown.
Consequence of step 3: a transaction is bound to one thread. Work handed to
@Async,CompletableFuture.supplyAsync, a parallel stream or a new thread does not participate in the caller’s transaction.- The bean is wrapped in a proxy (see Proxies & AOP) whose interceptor is
02`@Transactional` on a class vs on a method — override, not merge
#c-transactional-on-a-class-vs-on-a-method-override-not-mergeOn the class it applies to every eligible method; a method-level annotation overrides the class-level one entirely (attributes aren’t merged). Common pattern: class-level
@Transactional(readOnly = true)with method-level@Transactionalon writes.03When entities are actually saved — dirty checking and commit
#c-when-entities-are-actually-saved-dirty-checking-and-commitInside a transaction, every entity loaded through the
EntityManageris managed: the persistence context remembers a snapshot of its state. At flush (by default just before commit) Hibernate compares each managed entity against its snapshot and issuesUPDATEs for the changed ones. So you don’t need to callsave()on an entity you loaded and modified in the same transaction:@Transactional public void rename(long id, String name) { Customer c = customers.findById(id).orElseThrow(); c.setName(name); // no save() — UPDATE is issued at commit by dirty checking }Timing details interviewers like:
save()/persist()of a new entity usually only schedules theINSERT; SQL is sent at flush (exception:GenerationType.IDENTITYmust insert immediately to learn the id).- Flush also happens before a query whose result could depend on pending changes, and
when you call
flush()/saveAndFlush()explicitly. - Because SQL is sent late, constraint violations surface at flush/commit — after your
method body has returned but while still inside the proxy, so they land outside your own
try/catch. A unique-key violation or optimistic-lock failure then reaches the caller as aDataIntegrityViolationException/ObjectOptimisticLockingFailureException. Callflush()(or usesaveAndFlush()) inside the method if you need to surface and handle it there. - Without a transaction, Spring Data repository methods run in their own short
transactions; an entity you get back is detached once that method returns, and
modifying it changes nothing in the database unless you
save()(merge) it again.
04Rollback rules — checked vs unchecked exceptions
#c-rollback-rules-checked-vs-unchecked-exceptionsBy default Spring rolls back on
RuntimeExceptionandErroronly. A checked exception commits the transaction (an EJB-era convention: checked = “expected business outcome”).@Transactional(rollbackFor = Exception.class) // roll back on checked too @Transactional(noRollbackFor = NotificationFailed.class) // commit despite this oneAlso:
- Catching the exception inside the method means the interceptor never sees it → commit.
If you catch but still want a rollback, rethrow or call
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(). - The rule applies at the boundary of the proxied method, not wherever the exception was thrown.
- Catching the exception inside the method means the interceptor never sees it → commit.
If you catch but still want a rollback, rethrow or call
05UnexpectedRollbackException — the rollback-only trap
#c-unexpectedrollbackexception-the-rollback-only-trap@Transactional public void placeOrder() { orders.save(order); try { loyalty.addPoints(customer); // also @Transactional (REQUIRED) → joins this tx, throws } catch (RuntimeException e) { log.warn("points failed, continuing"); // we swallow it… } } // …commit → UnexpectedRollbackException: Transaction silently rolled backWhen
addPointsthrew, its interceptor saw aRuntimeExceptionand — since it only joined the outer transaction and can’t roll it back alone — marked the shared transaction rollback-only. The outer method then tries to commit, Spring finds the flag, rolls back and throwsUnexpectedRollbackException. Fix: run the optional part inREQUIRES_NEW(its own transaction, so its failure doesn’t poison the outer one), make it not throw, or let the whole thing fail.06Propagation levels
#c-propagation-levelsWhat happens when a
@Transactionalmethod is called and a transaction may already exist:Propagation Existing transaction No transaction REQUIRED(default)join it start new REQUIRES_NEWsuspend it, start an independent one start new NESTEDsavepoint inside it; inner rollback → back to savepoint only start new SUPPORTSjoin it run without MANDATORYjoin it throw NOT_SUPPORTEDsuspend it, run without run without NEVERthrow run without Interview points:
REQUIRES_NEWcommits independently of the outer transaction — typical for audit logs or “record the failed attempt even if the main work rolls back”. It needs a second connection while the outer one is suspended — under load, nestedREQUIRES_NEWcalls can exhaust the connection pool and deadlock the application.NESTEDuses JDBC savepoints: one physical transaction, partial rollback. Supported byDataSourceTransactionManager; generally not with JPA/Hibernate.- Which method commits? With
REQUIRED, only the outermost transactional method’s proxy begins and commits; inner methods just join, so nothing is committed when an inner method returns. WithREQUIRES_NEW, the inner method commits on its own return.
07Isolation, timeout and readOnly attributes
#c-isolation-timeout-and-readonly-attributesisolation—READ_COMMITTED,REPEATABLE_READ,SERIALIZABLE, … sets the JDBC connection’s isolation level for this transaction;DEFAULTuses the database’s default. Only applied when the transaction starts — an inner method joining an existing transaction can’t change it. (See the isolation subtopic for what each level prevents.)timeout(seconds) — the transaction is rolled back if it runs longer; Spring also applies it as a JDBC query timeout.readOnly = true— a hint with real effects under Hibernate: the session flush mode is set toMANUAL(no flush, no dirty checking at commit) and entities are loaded read-only (no snapshots kept → less memory and CPU). The JDBC connection is marked read-only, which some drivers/databases optimise, and a routingDataSourcecan use it to send the query to a read replica. It does not reliably prevent writes: modifications to loaded entities are silently not written (no flush, no dirty checking), but an explicit nativeUPDATE/INSERTmay still run, depending on the database and driver. It’s an optimisation hint, not access control — don’t rely on it as a security control.
A common pattern:
@Transactional(readOnly = true)on the service class, overridden with plain@Transactionalon the write methods.08@Transactional pitfalls checklist
#c-transactional-pitfalls-checklist- Self-invocation —
this.otherMethod()bypasses the proxy (see Proxies & AOP). - Private / final methods — not intercepted.
- Checked exceptions commit unless
rollbackForis set. - Swallowed exceptions — caught inside the method → commit.
- New threads /
@Async— don’t inherit the transaction. - Wrong annotation —
jakarta.transaction.Transactionalalso works in Spring but lacksreadOnly/propagation names; preferorg.springframework.transaction.annotation.Transactional. - Bean not managed by Spring —
new MyService()has no proxy. - Long transactions doing remote calls — an HTTP call inside a transaction holds a DB connection and row locks for its whole duration. Do I/O outside, keep transactions short.
- Multiple transaction managers — with two data sources you must specify
@Transactional("ordersTransactionManager"), otherwise the primary one is used and the other database runs in auto-commit. @Transactionalon interfaces — works with JDK proxies, but annotate the concrete class (or its methods) to be safe with CGLIB.
- Self-invocation —
09Where `@Transactional` belongs — the service layer, not controller or repository
#c-where-transactional-belongs-the-service-layer-not-controllerPut it on the service layer: a transaction should match one business use case, which typically spans several repository calls. Repository-level transactions (Spring Data’s defaults) are too fine-grained for multi-step operations, and controller-level transactions stretch across HTTP concerns and serialization.
10Doing work after commit — @TransactionalEventListener
#c-doing-work-after-commit-transactionaleventlistenerSending an email or publishing a Kafka message inside the transaction is wrong in both directions: if the transaction later rolls back, the email was sent for an order that doesn’t exist; if you send after the method returns, you’re outside the service boundary.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onOrderPlaced(OrderPlaced e) { mailer.sendConfirmation(e.orderId()); }The event is published during the transaction but the listener runs only after a successful commit (other phases:
BEFORE_COMMIT,AFTER_ROLLBACK,AFTER_COMPLETION). Note that atAFTER_COMMITthe transaction resources are still bound but the transaction is finished — writes in the listener needREQUIRES_NEW. For guaranteed delivery even if the app crashes right after commit, use the transactional outbox pattern (write the event to an outbox table in the same transaction; relay it asynchronously).11Programmatic transactions — TransactionTemplate
#c-programmatic-transactions-transactiontemplateWhen you need a transaction boundary narrower than a method, inside a loop, or inside the same class (avoiding self-invocation):
transactionTemplate.executeWithoutResult(status -> { repo.save(entity); if (invalid) status.setRollbackOnly(); });TransactionTemplateuses the samePlatformTransactionManagerand the same propagation / isolation settings, just without a proxy.12@Transactional in tests
#c-transactional-in-testsIn Spring tests (
@DataJpaTestis itself@Transactional; or@Transactionalon a@SpringBootTestclass), each test method runs in a transaction that is rolled back at the end — a clean database per test. The trap: because nothing is ever committed (and often never flushed), tests can pass while production fails — constraint violations, lazy-loading outside a transaction, andAFTER_COMMITlisteners never fire. CallentityManager.flush()in tests that assert on persistence, and use@Commitor non-transactional tests where commit behaviour matters.