What does Propagation.NESTED do
Spring implements NESTED through JDBC Savepoints (not through new transactions):
π’ Junior Level
Propagation.NESTED is a transaction propagation type in Spring that creates a βnestedβ transaction inside an already existing one. If the outer transaction rolls back, the nested one also rolls back. But if the nested one rolls back β the outer one can continue working.
Simple analogy: Youβre writing a document (outer transaction). Periodically you βsave a versionβ (savepoint). If a new edit ruins the document, you can roll back to the last version (savepoint) without losing all your previous work. But if you decide to trash the entire document β all versions disappear too.
SQL example (JDBC Savepoint):
@Service
public class BatchImportService {
@Autowired private NestedImportService nestedImportService;
@Transactional
public void importAll(List<Data> dataList) {
for (Data data : dataList) {
try {
// Each record β a nested "mini-transaction"
nestedImportService.importOne(data);
} catch (Exception e) {
// Only this record rolls back, others continue
log.warn("Failed to import: {}", data.getId());
}
}
// At the end β one common COMMIT for all successful records
}
}
@Service
public class NestedImportService {
@Transactional(propagation = Propagation.NESTED)
public void importOne(Data data) {
repo.save(data);
// If exception β rollback to savepoint (not the entire batch)
}
}
Key difference from REQUIRES_NEW:
- NESTED: One COMMIT at the very end, nested is a savepoint within the same transaction
- REQUIRES_NEW: Two independent COMMITs, inner transaction commits immediately
When to use:
- Batch data processing with acceptable partial failures
- When you need to βsaveβ part of the work, but the final commit must be atomic
π‘ Middle Level
How it works internally
Spring implements NESTED through JDBC Savepoints (not through new transactions):
Call: outerMethod() β innerMethod(NESTED)
1. Spring checks: is there an active transaction? β Yes
2. Spring creates JDBC Savepoint:
Connection conn = dataSource.getConnection();
Savepoint sp = conn.setSavepoint("NESTED_SAVEPOINT_1");
3. innerMethod() executes
4. If success β savepoint released (but NOT commit!)
5. If exception β conn.rollback(sp) β rollback to savepoint
6. At the end of outerMethod() β one common conn.commit()
Important: NESTED works only with DataSourceTransactionManager and JpaTransactionManager. It does NOT work with JTA in savepoint mode. JTA (Java Transaction API) β API for distributed transactions spanning multiple DBs or systems. Spring automatically falls back to REQUIRES_NEW behavior. So for JTA, itβs better to specify REQUIRES_NEW explicitly.
Practical application
Scenario 1: Batch import with partial failure tolerance
@Service
public class CsvImportService {
@Autowired private RowImportService rowImportService;
@Transactional
public ImportResult importCsv(MultipartFile file) {
int success = 0;
int failed = 0;
List<String> rows = parseCsv(file);
for (String row : rows) {
try {
rowImportService.importRow(row); // NESTED
success++;
} catch (ValidationException e) {
failed++;
// This row rolled back to savepoint, continue
}
}
return new ImportResult(success, failed);
// COMMIT β all successful rows saved
}
}
@Service
public class RowImportService {
@Transactional(propagation = Propagation.NESTED)
public void importRow(String row) {
Data data = validateAndParse(row); // may throw ValidationException
repo.save(data);
}
}
Scenario 2: Orchestration with compensation
@Service
public class OrderOrchestrator {
@Autowired private InventoryService inventoryService;
@Autowired private PaymentService paymentService;
@Autowired private NotificationService notificationService;
@Transactional
public OrderResult createOrder(Order order) {
try {
inventoryService.reserve(order.getItems()); // NESTED
} catch (Exception e) {
return OrderResult.failed("No stock");
}
try {
paymentService.charge(order.getTotal()); // NESTED
} catch (Exception e) {
// Payment failed β reservation also rolls back (common transaction)
throw new OrderException("Payment failed", e);
}
// All successful β notification (REQUIRED, part of common transaction)
notificationService.sendOrderConfirmation(order);
return OrderResult.success();
// COMMIT β all changes atomic
}
}
Typical mistakes
| Mistake | Consequence | Solution |
|---|---|---|
| NESTED without active outer transaction | Behaves like REQUIRED (creates new) | Ensure outer method is @Transactional |
| NESTED + Hibernate L1 cache inconsistency | Stale entities after savepoint rollback | em.flush() + em.clear() after rollback |
| NESTED with JTA | Not supported β fallback to REQUIRES_NEW | Use REQUIRES_NEW explicitly for JTA |
| Assuming NESTED = REQUIRES_NEW | Inner changes NOT visible to others until common commit | Understand: NESTED = savepoint, REQUIRES_NEW = new tx |
| Long transaction with many savepoints | Undo log accumulation, memory pressure | Limit number of nested calls |
Comparison: NESTED vs REQUIRES_NEW
| Characteristic | NESTED | REQUIRES_NEW |
|---|---|---|
| Physical transactions | 1 | 2 |
| Mechanism | JDBC Savepoint | Suspend + new transaction |
| Inner commit | No (only release savepoint) | Yes, immediate |
| Inner rollback | To savepoint | Full inner tx |
| Data visibility to other tx | Only after common commit | Immediately after inner commit |
| Outer tx rollback | Rolls back inner | Doesnβt affect inner |
| JTA support | No | Yes |
| Hibernate L1 cache | Issues on rollback | No issues |
| Connection usage | 1 connection | 2 connections (suspend + new) |
| Overhead | Low (~1-3ms) | Medium (~5-15ms) |
When NOT to use NESTED
- JTA / distributed transactions β not supported
- When inner transaction must commit independently β use REQUIRES_NEW
- With Hibernate without flush/clear β risk of L1 cache inconsistency
- For auditing β audit usually needed independently of outer tx, better use REQUIRES_NEW
When NESTED vs REQUIRES_NEW
- Audit/logging = REQUIRES_NEW (data must survive outer rollback)
- Batch processing with partial failure = NESTED (single commit, one connection)
- Distributed transaction = REQUIRES_NEW (NESTED doesnβt support JTA)
- Need to see inner data from other tx = REQUIRES_NEW (NESTED visible only after common commit)
π΄ Senior Level
Internal Implementation: Savepoint Mechanics and Spring Integration
JdbcTransactionObjectSupport β Savepoint Management
// org.springframework.jdbc.datasource.JdbcTransactionObjectSupport
// Spring Framework 6.x
public class JdbcTransactionObjectSupport implements SavepointManager, SmartTransactionObject {
private ConnectionHolder connectionHolder;
private Savepoint currentSavepoint;
private Integer previousIsolationLevel;
private boolean rollbackOnly = false;
private boolean savepointAllowed = false;
@Override
public Object createSavepoint(String name) throws TransactionException {
try {
if (!this.savepointAllowed) {
throw new NestedTransactionNotSupportedException(
"Cannot create savepoint β no active transaction or not supported");
}
// JDBC 3.0 API
this.currentSavepoint = getConnection().setSavepoint(name);
return this.currentSavepoint;
} catch (SQLException ex) {
throw new CannotCreateSavepointException("Could not create JDBC savepoint", ex);
}
}
@Override
public void rollbackToSavepoint(Object savepoint) throws TransactionException {
try {
Savepoint sp = (Savepoint) savepoint;
// JDBC rollback to savepoint
getConnection().rollback(sp);
// Important: connection remains active, transaction continues
this.currentSavepoint = null;
} catch (SQLException ex) {
throw new TransactionSystemException("Could not roll back to JDBC savepoint", ex);
}
}
@Override
public void releaseSavepoint(Object savepoint) throws TransactionException {
try {
Savepoint sp = (Savepoint) savepoint;
getConnection().releaseSavepoint(sp);
// Non-fatal if DB auto-releases on commit (MySQL)
} catch (SQLException ex) {
// Log but don't fail β some databases release savepoints automatically
}
}
}
AbstractPlatformTransactionManager β NESTED Handling
// Key fragment from handleExistingTransaction()
case PROPAGATION_NESTED:
if (!isNestedTransactionAllowed()) {
throw new NestedTransactionNotSupportedException(
"Transaction manager does not allow nested transactions");
}
if (useSavepointForNestedTransaction()) {
// DataSourceTransactionManager, JpaTransactionManager β savepoint
DefaultTransactionStatus status =
prepareTransactionStatus(definition, transaction, false, false, debugEnabled, null);
// Create savepoint
status.createAndHoldSavepoint();
return status;
} else {
// JTA β no savepoint support β fallback to REQUIRES_NEW
SuspendedResourcesHolder suspendedResources = suspend(transaction);
// ... creates a new transaction
}
useSavepointForNestedTransaction() Decision
// DataSourceTransactionManager β true (supports savepoints)
// JpaTransactionManager β true (delegates to JDBC)
// JtaTransactionManager β false (JTA doesn't support savepoints)
// HibernateTransactionManager β true (through JDBC connection)
protected boolean useSavepointForNestedTransaction() {
return (getDataSource() != null); // Simplified
}
Savepoint Implementation in Various DBs
PostgreSQL:
BEGIN;
SAVEPOINT sp1;
INSERT INTO orders (id, amount) VALUES (1, 100);
-- Rollback to savepoint
ROLLBACK TO SAVEPOINT sp1;
-- orders empty β insert undone, but transaction active
INSERT INTO orders (id, amount) VALUES (2, 200);
COMMIT;
-- orders: (2, 200)
-- PostgreSQL savepoints:
-- - Stored in memory (not on disk)
-- - Can be nested (savepoint within savepoint)
-- - Auto-released on commit
MySQL/InnoDB:
START TRANSACTION;
SAVEPOINT sp1;
INSERT INTO orders VALUES (1, 100);
ROLLBACK TO SAVEPOINT sp1;
-- In MySQL: auto-release savepoint after rollback!
-- Repeated ROLLBACK TO SAVEPOINT sp1 β ERROR
INSERT INTO orders VALUES (2, 200);
COMMIT;
-- MySQL savepoints:
-- - Auto-released after ROLLBACK TO (cannot reuse)
-- - Not logged in binlog (statement-based replication safe)
-- - This behavior is stable across all supported versions MySQL 5.7+ and PostgreSQL 9+.
-- Spring generates unique savepoint names starting from version 3.x.
Oracle:
-- Oracle savepoints:
-- - Can reuse name (unlike MySQL)
-- - Stored in PGA (memory)
-- - Support nested savepoints
Architectural Trade-offs
Approach A: NESTED (savepoints)
- β Pros: One connection, single commit, partial rollback, lower overhead than REQUIRES_NEW
- β Cons: Doesnβt work with JTA, Hibernate L1 cache issues, savepoint limitations (MySQL auto-release), no independent commit
- Suitable for: batch processing, bulk operations, compensation patterns within a single DB
Approach B: REQUIRES_NEW
- β Pros: Full isolation, works with JTA, independent commit, clean Hibernate semantics
- β Cons: Two connections, suspend/resume overhead, inconsistency risk (inner committed, outer rolled back)
- Suitable for: audit, notification, cross-database operations
Approach C: Manual savepoint management
@Transactional
public void manualSavepoint() {
Connection conn = DataSourceUtils.getConnection(dataSource);
Savepoint sp = conn.setSavepoint("my_sp");
try {
// business logic
} catch (Exception e) {
conn.rollback(sp); // Manual rollback
}
// ...
}
- β Pros: Full control, can integrate with any logic
- β Cons: Boilerplate, error-prone, bypasses Spring transaction management
- Suitable for: complex scenarios where Spring propagation is insufficient
Edge Cases and Corner Cases
1. MySQL Savepoint Auto-Release:
@Transactional
public void mysqlSavepointIssue() {
nestedService.doWork(); // NESTED β SAVEPOINT sp1
// If rollback to sp1 inside doWork() β MySQL auto-releases sp1
nestedService.doWork2(); // NESTED β tries to create SAVEPOINT sp1 again
// PostgreSQL: OK (savepoint can be reused)
// MySQL: OK (new name sp2)
// But if Spring reuses the same name β MySQL ERROR:
// "Savepoint 'NESTED_SAVEPOINT_1' does not exist"
}
Spring solves this by generating unique names:
// DefaultTransactionStatus
private String generateSavepointName() {
return "NESTED_SAVEPOINT_" + System.identityHashCode(this);
}
2. Hibernate L1 Cache and Savepoint Rollback:
@Transactional
public void hibernateSavepointIssue() {
Entity e1 = new Entity("first");
em.persist(e1);
em.flush(); // INSERT into DB
try {
nestedService.failOperation(); // NESTED β throws exception
} catch (Exception ex) {
// Savepoint rollback: DB rolled back INSERT
// BUT: Hibernate L1 cache (PersistenceContext) still contains e1!
// e1.status = MANAGED, but no row in DB
}
// On flush/commit:
// Hibernate may try UPDATE e1 β ERROR (row doesn't exist)
// Or: Hibernate dirty checking β INSERT e1 again β duplicate
}
Solution:
@Transactional(propagation = Propagation.NESTED)
public void failOperation() {
Entity e = new Entity("will-fail");
em.persist(e);
em.flush();
// After flush, if exception β savepoint rollback removes INSERT from DB
// But L1 cache still contains e β need em.clear()
throw new RuntimeException("fail");
}
// Outer method:
@Transactional
public void safeNestedOperation() {
try {
nestedService.failOperation();
} catch (Exception e) {
em.clear(); // Clear L1 cache from potentially rolled-back entities
}
}
Step by step: (1) em.persist() registers entity in PersistenceContext as MANAGED.
(2) em.flush() sends INSERT to DB. (3) Exception triggers savepoint rollback β
INSERT removed from DB. (4) But PersistenceContext still contains entity with
MANAGED status. (5) On final commit, Hibernate tries to flush again β error.
em.clear() solves the problem by discarding the entire PersistenceContext.
3. Nested Savepoints (savepoint within savepoint):
@Transactional
public void deeplyNested() {
level1Service.doWork(); // NESTED β SAVEPOINT sp1
try {
level2Service.doWork(); // NESTED β SAVEPOINT sp2 (inside sp1)
} catch (Exception e) {
// Rollback to sp2 β sp1 still active
}
try {
level3Service.doWork(); // NESTED β SAVEPOINT sp3
} catch (Exception e) {
// Rollback to sp3 β sp1 and sp2 (if not released) active
}
}
// Limit: PostgreSQL β unlimited nested savepoints
// MySQL β unlimited, but each rollback auto-releases
// Oracle β unlimited
4. Savepoint and Sequence (PostgreSQL):
BEGIN;
SAVEPOINT sp1;
SELECT nextval('my_seq'); -- returns 1
ROLLBACK TO SAVEPOINT sp1;
-- Sequence value NOT rolled back!
SELECT nextval('my_seq'); -- returns 2, not 1
-- This is because sequence is a non-transactional structure
Impact: If business logic relies on sequence values inside nested transactions, there will be gaps.
5. Savepoint and Temporary Tables:
-- PostgreSQL: temporary tables do NOT rollback to savepoint
BEGIN;
CREATE TEMP TABLE tmp_data (id INT);
SAVEPOINT sp1;
INSERT INTO tmp_data VALUES (1);
ROLLBACK TO SAVEPOINT sp1;
SELECT * FROM tmp_data; -- EMPTY β rollback worked
-- BUT: DROP TEMP TABLE does NOT rollback
SAVEPOINT sp2;
DROP TABLE tmp_data;
ROLLBACK TO SAVEPOINT sp2;
SELECT * FROM tmp_data; -- ERROR: table doesn't exist!
6. Savepoint and Triggers:
-- If trigger throws exception inside nested transaction:
CREATE TRIGGER order_before_insert
BEFORE INSERT ON orders
FOR EACH ROW
EXECUTE FUNCTION validate_order();
-- If trigger fails:
-- Savepoint rollback undoes INSERT
-- BUT: trigger side-effects (e.g., sequence nextval) are not rolled back
Performance Implications
| Operation | Latency | Throughput Impact |
|---|---|---|
| Savepoint creation | ~0.1ms | Negligible |
| Savepoint release | ~0.05ms | Negligible |
| Rollback to savepoint | ~0.5-2ms (depends on change count) | Low |
| 10 nested savepoints | ~1-3ms total | -2-5% |
| 100 nested savepoints | ~10-30ms | -10-15% (undo log growth) |
Concrete numbers (Spring Boot 3.x, PostgreSQL 15, HikariCP pool=50):
- REQUIRED only: ~25,000 TPS
- REQUIRED + 1 NESTED: ~24,000 TPS (savepoint overhead ~4%)
- REQUIRED + 10 NESTED: ~22,000 TPS (cumulative savepoint overhead)
- REQUIRED + 1 REQUIRES_NEW: ~20,000 TPS (suspend/resume ~20%)
Memory overhead:
- Savepoint object: ~500 bytes (JDBC) + DB-side state
- PostgreSQL: savepoint in shared memory, ~1KB per savepoint
- MySQL: savepoint in InnoDB trx struct, ~2KB per savepoint
- Undo log retention: with long transactions and savepoints β dead tuples accumulate
Memory Implications
- JDBC Savepoint: ~500 bytes per savepoint object (client-side)
- DB-side savepoint state: 1-2KB per savepoint in shared memory
- Undo log / dead tuples: Each operation inside savepoint creates undo records. On rollback β undo records not freed until main commit.
- Hibernate L1 cache: May contain stale entities after savepoint rollback β up to several MB for large operations.
- Connection holder state: Spring stores savepoint reference in TransactionStatus β ~200 bytes per nested level.
Concurrency Aspects
NESTED concurrency model:
Thread-1: outerMethod(REQUIRED)
β nestedService.doWork(NESTED) β savepoint sp1
β If exception: rollback to sp1 (only Thread-1 affected)
β nestedService.doWork2(NESTED) β savepoint sp2
β commit sp2 (release)
β commit outer (commit all)
Thread-2: outerMethod(REQUIRED)
β nestedService.doWork(NESTED) β savepoint sp3
Thread-1 and Thread-2 use different connections β independent
Savepoints visible only within their own transaction
Savepoint and lock behavior:
-- Row locks acquired inside savepoint released on rollback to savepoint
BEGIN;
SAVEPOINT sp1;
UPDATE accounts SET balance = 100 WHERE id = 1; -- row lock acquired
ROLLBACK TO SAVEPOINT sp1;
-- Row lock released!
-- BUT: DDL inside savepoint (PostgreSQL):
SAVEPOINT sp2;
ALTER TABLE orders ADD COLUMN new_col TEXT; -- DDL
ROLLBACK TO SAVEPOINT sp2;
-- DDL NOT rolled back to savepoint! (DDL implicit commit)
Real Production Scenario
Situation: Log aggregation platform (2024), Spring Boot 3.1, PostgreSQL, ingestion 50,000 events/sec.
Problem: During batch log insertion (1000 events/batch), one malformed event caused rollback of the entire batch. Throughput dropped 30% due to reprocessing.
Original code:
@Transactional
public void ingestBatch(List<LogEvent> events) {
for (LogEvent event : events) {
LogEvent validated = validate(event); // May throw ValidationException
logRepo.save(validated);
}
// One ValidationException β rollback entire batch (1000 events lost)
}
Attempt #1: try-catch inside REQUIRED
@Transactional
public void ingestBatch(List<LogEvent> events) {
for (LogEvent event : events) {
try {
LogEvent validated = validate(event);
logRepo.save(validated);
} catch (ValidationException e) {
failedCount++;
// Problem: transaction marked rollback-only!
// On commit: UnexpectedRollingBackException
}
}
}
Final solution: NESTED
@Autowired private EventImportService eventImportService;
@Transactional
public void ingestBatch(List<LogEvent> events) {
int success = 0, failed = 0;
for (LogEvent event : events) {
try {
eventImportService.importEvent(event); // NESTED
success++;
} catch (ValidationException e) {
failed++;
// Only this event rolled back to savepoint
}
}
metrics.record(success, failed);
// COMMIT β all successful events saved
}
@Service
public class EventImportService {
@Transactional(propagation = Propagation.NESTED)
public void importEvent(LogEvent event) {
LogEvent validated = validate(event); // ValidationException β savepoint rollback
logRepo.save(validated);
// No exception β savepoint released
}
}
Result:
- Batch completion rate: from 70% (30% rollback) to 99.5%
- Throughput: from 35,000 to 48,000 events/sec (+37%)
- Latency p99: from 200ms to 50ms (fewer retries)
- Trade-off: ~3% overhead from savepoint management
Monitoring and Diagnostics
Track savepoint creation and rollback:
@Component
public class SavepointMonitor implements TransactionSynchronization {
private final MeterRegistry registry;
@Override
public void beforeCommit(boolean readOnly) {
// Track savepoint usage
}
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
Counter.builder("transaction.savepoint.rollback")
.increment();
}
}
}
// Register
TransactionSynchronizationManager.registerSynchronization(new SavepointMonitor(registry));
Hibernate L1 cache monitoring:
@PersistenceUnit
private EntityManagerFactory emf;
public void checkL1CacheSize() {
SessionFactoryImpl sf = emf.unwrap(SessionFactoryImpl.class);
for (Session s : sf.getSessions()) {
PersistenceContext pc = s.getPersistenceContext();
int entityCount = pc.getEntitiesByKey().size();
if (entityCount > THRESHOLD) {
log.warn("Large L1 cache after nested operations: {}", entityCount);
}
}
}
Best Practices for Highload
- Limit nesting depth β no more than 10-20 nested savepoints per transaction.
- Use NESTED only for partial failure tolerance β not for business logic.
- Flush + Clear after nested rollback with Hibernate:
try { nestedService.doWork(); } catch (Exception e) { em.clear(); // Required after savepoint rollback } - Avoid sequence dependencies inside nested transactions β gaps are inevitable.
- Monitor savepoint rollback rate β high rate indicates validation problems, not transaction issues.
π― Interview Cheat Sheet
Must know:
- NESTED creates a JDBC savepoint within the existing transaction β NOT a new physical transaction
- If outer tx rolls back β nested also rolls back. If nested rolls back β outer can continue
- Key difference from REQUIRES_NEW: NESTED = one commit at the end, REQUIRES_NEW = independent commit
- NESTED doesnβt work with JTA β Spring falls back to REQUIRES_NEW
- Hibernate L1 cache is NOT rolled back with savepoint β need em.flush() + em.clear()
- Savepoints are per-DB: MySQL auto-releases after rollback, PostgreSQL allows reuse
Common follow-up questions:
- NESTED vs REQUIRES_NEW? β NESTED: savepoint, single commit; REQUIRES_NEW: suspend + new tx, independent commit
- What happens if outer tx rolls back with NESTED? β Everything rolls back (savepoint included)
- Does NESTED work with JTA? β No, JTA doesnβt support savepoints, falls back to REQUIRES_NEW
- Why is Hibernate L1 cache a problem with NESTED? β PersistenceContext not rolled back with savepoint
Red flags (DO NOT say):
- βNESTED creates a new transactionβ β it creates a savepoint within the same transaction
- βNESTED works with JTAβ β JTA doesnβt support savepoints
- βNested data is visible to other transactions immediatelyβ β only visible after common commit
Related topics:
- [[13. What is Propagation in Spring]]
- [[15. What is the difference between REQUIRED and REQUIRES_NEW]]
- [[18. What is rollback in transactions]]