Transactions Interview Questions & Answers
Declarative transactions in Spring Boot — @Transactional, propagation and isolation levels, rollback rules, readOnly, the self-invocation proxy pitfall, optimistic vs pessimistic locking, and the persistence-context flush boundary.
@Transactional declares a method (or class) should run inside a database transaction —
Spring opens one before the method, commits if it returns normally, and rolls back if it
throws. You write business logic; Spring manages begin/commit/rollback.
@Transactional
void transfer(Long from, Long to, BigDecimal amount) {
accounts.debit(from, amount);
accounts.credit(to, amount); // if this throws, the debit is rolled back too
}
It's declarative transaction management: no manual connection.commit()/rollback(). Under
the hood Spring wraps the bean in an AOP proxy that starts a transaction, binds the connection to
the thread, and finalizes it around your method.
Rule of thumb: Put @Transactional on the service method that represents one unit of work;
Spring handles commit on success and rollback on failure.
On the service layer — the method that represents a complete business operation — not on controllers or repositories. The service is where one unit of work spans multiple repository calls.
@Service
class OrderService {
@Transactional // one transaction wraps both writes
void placeOrder(OrderRequest req) {
Order o = orderRepo.save(toOrder(req));
inventoryRepo.decrement(req.items()); // commits together or rolls back together
}
}
Spring Data repository methods are already transactional individually, but a business operation usually touches several repositories — those must share one transaction so they succeed or fail atomically. Controllers should stay free of persistence concerns.
Rule of thumb: Annotate service methods (the unit of business work); don't scatter
@Transactional on controllers or rely on per-repository transactions for multi-step operations.
By default Spring rolls back only on unchecked exceptions (RuntimeException) and Error —
checked exceptions commit. This surprises people constantly.
@Transactional
void process() throws IOException {
repo.save(entity);
throw new IOException("boom"); // CHECKED → transaction COMMITS, save persists!
}
@Transactional(rollbackFor = Exception.class) // force rollback on checked too
void safe() throws IOException { ... }
To roll back on a checked exception, add rollbackFor. To commit despite a runtime exception,
use noRollbackFor. The default mirrors the old EJB convention but trips up anyone who throws a
checked exception expecting a rollback.
Rule of thumb: Remember checked exceptions commit by default; add rollbackFor = Exception.class when a checked failure must roll the transaction back.
Propagation.REQUIRED (the default) means: join the caller's transaction if one exists,
otherwise start a new one. Nested @Transactional calls then share a single physical
transaction.
@Transactional // REQUIRED — starts a transaction
void outer() { inner(); }
@Transactional // REQUIRED — JOINS outer's transaction
void inner() { ... } // both commit together; if inner throws, all rolls back
The consequence: there's one commit/rollback for the whole chain. If inner throws and the
exception propagates, the entire outer transaction rolls back — even work done before
inner. The transaction is the outermost boundary, not per-method.
Rule of thumb: With the default REQUIRED, nested transactional calls share one transaction —
one failure rolls back the whole unit of work.
REQUIRES_NEW suspends any existing transaction and runs in a brand-new, independent one —
so it commits or rolls back regardless of the outer transaction's outcome.
@Transactional
void placeOrder(...) {
process();
auditLog.record(event); // see below — must persist even if placeOrder rolls back
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
void record(AuditEvent e) { auditRepo.save(e); } // independent commit
Classic uses: audit logs or status updates that must survive even when the main work fails. The cost: it holds two connections at once (outer suspended, inner active), so overuse can exhaust the pool. Use it deliberately for genuinely independent side effects.
Rule of thumb: Use REQUIRES_NEW for side effects (audit, logging) that must commit
independently of the surrounding transaction — sparingly, since it consumes a second connection.
Beyond REQUIRED and REQUIRES_NEW, JPA/Spring offers:
@Transactional(propagation = Propagation.SUPPORTS) // join if present, else run non-tx
@Transactional(propagation = Propagation.MANDATORY) // must have a tx, else throw
@Transactional(propagation = Propagation.NOT_SUPPORTED)// suspend any tx, run non-tx
@Transactional(propagation = Propagation.NEVER) // must NOT have a tx, else throw
@Transactional(propagation = Propagation.NESTED) // savepoint inside the current tx
MANDATORY— enforces the caller already started a transaction.SUPPORTS— uses one if present but doesn't require it.NESTED— creates a savepoint; the inner part can roll back without killing the outer (JDBC-savepoint based, not on all setups).
In practice you rarely need more than REQUIRED and REQUIRES_NEW.
Rule of thumb: Know MANDATORY/SUPPORTS/NESTED exist, but default to REQUIRED and reach
for REQUIRES_NEW only for independent commits.
Isolation controls which concurrency anomalies a transaction can see. From weakest to strongest:
@Transactional(isolation = Isolation.READ_COMMITTED)
READ_UNCOMMITTED— can see uncommitted data (dirty reads).READ_COMMITTED— only committed data; still allows non-repeatable reads. (Default on most databases like PostgreSQL/Oracle/SQL Server.)REPEATABLE_READ— same row reads identically within the tx; phantom rows still possible. (MySQL InnoDB default.)SERIALIZABLE— full isolation, as if transactions ran one at a time; slowest.
Higher isolation = fewer anomalies but more locking/contention. DEFAULT defers to the database's
own default.
Rule of thumb: Stick with the database default (usually READ_COMMITTED) and raise isolation
only for specific consistency needs — higher levels cost concurrency.
It hints that the transaction performs no writes, letting Hibernate and the database optimize. The biggest win is that Hibernate skips dirty checking and won't flush.
@Transactional(readOnly = true)
List<Order> listOrders() {
return repo.findAll(); // no flush, no snapshot tracking, lighter persistence context
}
Benefits: Hibernate sets FlushMode.MANUAL (no auto-flush, no dirty-check overhead), and the JDBC
connection/driver may route to a read replica or use a read-only optimization. It's not a hard
guarantee against writes, but accidental modifications won't be flushed.
Rule of thumb: Mark query/read service methods readOnly = true — it removes dirty-checking
overhead and signals intent (and can route to read replicas).
Because @Transactional works through a proxy. A call from one method to another in the same
bean (this.method()) bypasses the proxy entirely, so the annotation does nothing.
@Service
class OrderService {
void outer() {
inner(); // ❌ direct this.inner() — proxy NOT involved, no transaction!
}
@Transactional
void inner() { ... }
}
The proxy wraps the bean from outside; an internal this. call goes straight to the target,
skipping the AOP advice. Fixes: move inner to a separate bean, inject the proxy into itself,
or use TransactionTemplate. This is the single most common "my transaction isn't working" bug.
Rule of thumb: Transactional self-invocation doesn't work — call transactional methods from a different bean so the proxy can intervene.
For the same proxy reason as self-invocation. Spring's proxies can only intercept calls they can
override or wrap — private methods aren't visible to the proxy, and final methods can't be
overridden by a CGLIB subclass proxy.
@Transactional
private void doWork() { ... } // ❌ ignored — proxy can't intercept a private method
@Transactional
public final void doWork2() { } // ❌ CGLIB can't override a final method
@Transactional must go on a public, non-final method (and the bean must be a Spring-managed
proxy). The annotation on a private/final method compiles fine but is silently ineffective — no
error, no transaction.
Rule of thumb: Put @Transactional only on public, non-final methods of Spring beans;
private/final methods silently get no transaction.
Flush synchronizes the persistence context to the database by issuing the pending SQL; commit ends the transaction and makes those changes permanent and visible to others. Flush is not commit.
@Transactional
void demo() {
repo.save(a); // queued in the persistence context
em.flush(); // SQL sent to DB — but still inside the open transaction
// ... other work ...
} // commit happens here: changes become durable & visible
Hibernate auto-flushes before queries that might be affected and at commit. A flushed-but-uncommitted change is visible within the same transaction (and lockable) but can still be rolled back. Only commit releases locks and exposes the data to other transactions.
Rule of thumb: Flush sends SQL within the transaction (still rollback-able); commit finalizes it — don't equate the two.
Optimistic locking assumes conflicts are rare: instead of locking rows, it adds a @Version
column and checks it on update. If two transactions edit the same row, the second to commit fails.
@Entity class Account {
@Id Long id;
@Version int version; // Hibernate increments it on each update
BigDecimal balance;
}
// UPDATE account SET balance=?, version=version+1 WHERE id=? AND version=?
// 0 rows updated → OptimisticLockException (someone else changed it first)
The WHERE version = ? clause is the whole mechanism: a stale version updates zero rows and
Hibernate throws OptimisticLockException. No database locks are held, so it scales well under low
contention. You handle the exception by retrying with fresh data.
Rule of thumb: Use @Version optimistic locking for low-contention concurrent edits; catch the
lock exception and retry rather than holding locks.
Pessimistic locking takes a database lock on the rows up front (SELECT ... FOR UPDATE),
forcing other transactions to wait. Use it when contention is high and a retry loop would be
wasteful or unsafe.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Optional<Account> findForUpdate(@Param("id") Long id);
// emits SELECT ... FOR UPDATE — other tx block until this one commits
It guarantees you the latest data and exclusive access, but holds the lock for the transaction's duration — risking contention, deadlocks, and timeouts. Set a lock timeout. Choose optimistic for mostly-read/low-conflict data, pessimistic for hot rows where conflicts are frequent (inventory counters, seat reservations).
Rule of thumb: Pessimistic locking for high-contention hot rows where retries are costly; optimistic for the common low-conflict case.
Because Spring binds the transaction (and the persistence context) to the current thread via
ThreadLocal. An @Async method or a manually spawned thread runs on a different thread, so it
sees no transaction and no session.
@Transactional
void parent() {
child(); // @Async → runs on another thread → NOT in parent's transaction
}
@Async
void child() {
entity.getLazyField(); // ❌ no session → LazyInitializationException
}
The async method needs its own @Transactional if it touches the database, and it can't share
the parent's uncommitted state or lazy proxies. This also means you can't roll back an async
operation by failing the parent. Treat the async task as a fully independent unit of work.
Rule of thumb: Transactions are thread-bound — an @Async or new-thread method gets no inherited
transaction; give it its own @Transactional and don't pass entities/lazy proxies across the
thread boundary.
More Data Access interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.