Demystifying Spring’s Transaction Propagation: How Each Type Affects Your Data
Understand What Really Happens Behind @Transactional(REQUIRED, REQUIRES_NEW, etc.) — And How to Use Each Propagation Type the Right Way
Database transactions are a critical component of building reliable software — especially in domains like finance or ecommerce, where data consistency is non-negotiable.
A single failure could mean an incorrect account balance or a lost order, directly impacting user trust and business operations.
To help developers manage these concerns, Spring provides a powerful abstraction for handling transactions, ensuring data consistency, atomicity, and integrity with minimal boilerplate.
If you’re a Java or Kotlin Developer working with Spring, understanding the following transaction propagation types is essential:
REQUIRED
REQUIRES_NEW
MANDATORY
NEVER
SUPPORTS
NOT_SUPPORTED
NESTED
REQUIRED
The most commonly used transaction propagation level is REQUIRED. In fact, this is the default behavior applied when you use @Transactional without explicitly specifying a propagation type.
@Transactional
fun addProduct() {
// some logic here
}or
@Transactional(propagation = Propagation.REQUIRED)
fun addProduct() {
// some logic here
}Imagine a call chain like execOne() -> execTwo():
If
execOne()is the first method in the chain and is not already part of a transaction, it will start a new one.When it calls
execTwo(), andexecTwo()is annotated withREQUIRED, it will simply join the existing transaction instead of creating a new one.
If a transaction is already active, any method annotated with REQUIRED will reuse the current transaction context.
✅ Example
@Service
class ProductService(
private val productRepository: ProductRepository
) {
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Lego”, “Earphones”)
@Transactional
fun addProduct() { // Transaction starts here
(1..10).forEach { productId ->
productRepository.saveProduct(”${products.random()}-$productId”)
}
} // Transaction commits here
}
@Repository
class ProductRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional
fun saveProduct(name: String) {
jdbcTemplate.update(”INSERT INTO products(name) VALUES (?)”, name)
}
}If this method completes without any exceptions, all 10 products will be successfully inserted into the database under a single transaction.
⚠️ Exception Handling
@Transactional
fun addProduct() { // Transaction starts here
(1..10).forEach { productId ->
productRepository.saveProduct(”${products.random()}-$productId”)
if (productId == 7) error(”Network error”) // Simulating a failure
}
} // Transaction attempts to commit hereIf an exception is thrown during execution — and both addProduct() and saveProduct() use REQUIRED—then the entire transaction will be rolled back.
In the example above, even though the first seven products are saved successfully, the exception at productId == 7 triggers a rollback, resulting in zero rows being persisted.
💡 Summary
All database operations in this scenario share the same transaction and database connection. As a result, they are all tightly coupled — if any part fails and the transaction is rolled back, none of the operations will be persisted.
✅ Use
REQUIREDwhen data consistency and atomicity are essential, and you want all operations to succeed or fail as one unit.
REQUIRES_NEW
The REQUIRES_NEW propagation level always starts a new, independent transaction, even if one already exists.
This means that:
If a method with
@Transactional(propagation = REQUIRES_NEW)is called from within another transactional method, it will suspend the current transaction and create a completely separate transaction.The two transactions are isolated from each other in terms of commit and rollback behavior.
✅ Example
Assume the following setup:
@Service
class ProductService(
private val productRepository: ProductRepository
) {
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Lego”, “Earphones”)
@Transactional
fun addProduct() { // Transaction A starts
(1..10).forEach { productId ->
productRepository.saveProduct(”${products.random()}-$productId”)
}
} // Transaction A commits
}
@Repository
class ProductRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun saveProduct(name: String) { // Transaction B starts
jdbcTemplate.update(”INSERT INTO products(name) VALUES (?)”, name)
} // Transaction B commits
}Each call to saveProduct() starts a new transaction (B), independent of the outer transaction (A) started by addProduct().
⚠️ Exception Handling
Now let’s simulate a failure scenario:
@Transactional(propagation = Propagation.REQUIRED)
fun addProduct() {
(1..10).forEach { productId ->
productRepository.saveProduct(”${products.random()}-$productId”)
if (productId == 7) error(”Network error”)
}
}Here’s what happens:
addProduct()starts a transactionAEach call to
saveProduct()starts its own transactionB1...B10If an exception occurs at
productId == 7, transactionAwill roll backBut transactions
B1toB7(those insidesaveProduct) were already committed successfullyHence, those inserts will remain in the database, even though the outer method failed
In short:
✅ Transactions created by
REQUIRES_NEWare not affected by the rollback of the outer transaction.
💡 When to Use REQUIRES_NEW
Use REQUIRES_NEW when:
You want to persist partial progress, even if the outer transaction fails
You’re doing auditing/logging/error recording that must not be rolled back with the main operation
You need to ensure complete isolation between operations
🔄 Summary
REQUIRES_NEWalways creates a new transactionThe outer and inner transactions are completely independent
Inner transactions commit or roll back independently
Outer transaction rollback does not affect the committed inner transactions
MANDATORY
The MANDATORY propagation level enforces that the method must be called within an existing transaction. If no transaction is present when the method is invoked, Spring will throw an exception:
org.springframework.transaction.IllegalTransactionStateException:
No existing transaction found for transaction marked with propagation ‘mandatory’This is useful when you want to ensure that certain operations are always executed within a transactional context and should never start a transaction by themselves.
✅ Example: Without a Transaction
@Service
class ProductService(
private val productRepository: ProductRepository
) {
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Lego”, “Earphones”)
fun addProduct() {
(1..10).forEach { productId ->
productRepository.addProduct(”${products.random()}-$productId”)
}
}
}
@Repository
class ProductRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional(propagation = Propagation.MANDATORY)
fun addProduct(name: String) {
jdbcTemplate.update(”INSERT INTO products(name) VALUES (?)”, name)
}
}In this scenario, addProduct() in ProductService is not annotated with @Transactional, so when it calls addProduct() in ProductRepository, Spring throws an exception because there is no active transaction.
✅ Example: With a Transaction
To make it work, the calling method must provide a transaction. This can be done using either REQUIRED or REQUIRES_NEW:
@Service
class ProductService(
private val productRepository: ProductRepository
) {
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Lego”, “Earphones”)
@Transactional(propagation = Propagation.REQUIRED)
fun addProduct() {
(1..10).forEach { productId ->
productRepository.addProduct(”${products.random()}-$productId”)
}
}
}
@Repository
class ProductRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional(propagation = Propagation.MANDATORY)
fun addProduct(name: String) {
jdbcTemplate.update(”INSERT INTO products(name) VALUES (?)”, name)
}
}Now, the @MANDATORY method will execute successfully, because it is participating in the transaction started by the outer method.
⚠️ Exception Handling
Let’s simulate an exception within the transaction:
@Transactional
fun addProduct() {
(1..10).forEach { productId ->
productRepository.addProduct(”${products.random()}-$productId”)
if (productId == 7) error(”Network error”)
}
}Since both the outer method and inner method participate in the same transaction, if any part fails:
The entire transaction is marked for rollback
All previous inserts will be undone
This ensures strong atomicity and consistency — either all operations succeed, or none are applied.
💡 When to Use MANDATORY
Use MANDATORY when:
You want to enforce that a method is always part of a transaction
You want to fail fast if a transaction context is missing
You’re working in critical paths where accidental non-transactional calls must be avoided
🔄 Summary
MANDATORYrequires an existing transactionIt will throw an exception if called outside a transaction
It behaves like
REQUIRED, but does not start a transaction on its ownIdeal for enforcing transactional boundaries
NEVER
The NEVER propagation level explicitly declares that a method must not run inside a transaction.
If the method is called while a transaction is active, Spring will throw an exception:
org.springframework.transaction.IllegalTransactionStateException:
Existing transaction found for transaction marked with propagation ‘never’This is useful when you want to guarantee non-transactional execution, such as for:
Performance-critical read operations
External API calls
Legacy code not designed for transactional semantics
✅ Example: Called from a Transactional Method ❌
@Service
class ProductService(
private val productRepository: ProductRepository
) {
@Transactional
fun listProducts(): List<Product> = productRepository.listProducts()
}
@Repository
class ProductRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional(propagation = Propagation.NEVER)
fun listProducts(): List<Product> = jdbcTemplate.query(”SELECT * from products”) { rs, _ ->
Product(id = rs.getLong(”id”), name = rs.getString(”name”))
}
}Since listProducts() in ProductRepository is marked with NEVER, and it’s being called from a transactional method, Spring will throw an IllegalTransactionStateException.
✅ Example: Called from a Non-Transactional Method ✅
@Service
class ProductService(
private val productRepository: ProductRepository
) {
fun listProducts(): List<Product> = productRepository.listProducts()
}Now, since listProducts() is being called outside any transaction, the call proceeds successfully.
⚠️ Why Would You Avoid Transactions?
Transactions come with overhead and should be avoided when:
You’re just reading data (and isolation is not critical)
You’re performing operations that must be non-transactional, such as:
Audit logging to external systems
Sending emails or push notifications
You want to fail fast if a transaction context accidentally exists
💡 When to Use NEVER
Use NEVER when:
You want strict enforcement that a method runs without any transaction
You want to avoid transaction overhead
You need to ensure side effects are not tied to transactional behavior
🔄 Summary
NEVERforbids running inside a transactionIf called within a transaction, an exception is thrown
Ensures strict non-transactional behavior
Useful for performance optimization and external side-effect operations
SUPPORTS
The SUPPORTS propagation level is flexible — it allows a method to participate in a transaction if one exists, but won’t start a new transaction if there isn’t one.
This means:
If the caller is already in a transaction, the method joins it.
If there is no transaction, the method simply executes non-transactionally.
This can be useful for optional transactional behavior, especially in service or utility methods where full consistency isn’t always necessary.
✅ Example: Called from a Transactional Method
@Service
class ProductService(
private val productRepository: ProductRepository
) {
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Lego”, “Earphones”)
@Transactional(propagation = Propagation.REQUIRED)
fun addProduct() {
(1..10).forEach { productId ->
productRepository.saveProduct(”${products.random()}-$productId”)
if (productId == 7) error(”Network error”)
}
}
}
@Repository
class ProductRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional(propagation = Propagation.SUPPORTS)
fun saveProduct(name: String) {
jdbcTemplate.update(”INSERT INTO products(name) VALUES (?)”, name)
println(”Product with name: $name saved!”)
}
}Since saveProduct() is called from a transactional method, it participates in the existing transaction.
When
productId == 7, an exception is thrown.The transaction is marked for rollback.
None of the inserted products are saved in the database.
✅ This ensures atomicity — all or nothing — when used in a transactional context.
✅ Example: Called from a Non-Transactional Method
@Service
class ProductService(
private val productRepository: ProductRepository
) {
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Lego”, “Earphones”)
fun addProduct() {
(1..10).forEach { productId ->
productRepository.saveProduct(”${products.random()}-$productId”)
if (productId == 7) error(”Network error”)
}
}
}In this case:
addProduct()does not create a transaction.saveProduct()runs non-transactionally, as allowed bySUPPORTS.Products
1to7are saved.When the exception is thrown at
productId = 7, no rollback occurs.First seven products remain saved in the database.
❗ There is no rollback behavior if no transaction exists — so use with caution where consistency is important.
💡 When to Use SUPPORTS
Use SUPPORTS when:
The method should work both with or without a transaction
Transactions are optional, not mandatory
You want to reuse existing transaction context when available, but avoid enforcing one
🔄 Summary
Participates in an existing transaction if available
Executes non-transactionally otherwise
No rollback support if an error occurs and no transaction exists
Useful for optional consistency and reusable service layers
Awesome — here’s the polished and formatted section for NOT_SUPPORTED in Markdown:
NOT_SUPPORTED
The NOT_SUPPORTED propagation level tells Spring that the method must never run within a transaction.
Unlike NEVER, it doesn’t throw an exception if a transaction exists. Instead, it will pause (suspend) the current transaction (if any), execute the method non-transactionally, and resume the transaction after the method completes.
🔍 Behavior Overview
This is particularly useful when you want to intentionally avoid rollback, or execute operations outside of the current transaction context — such as:
Logging
Caching
Calling slow APIs
Sending emails / notifications
✅ Case 1: Called from a Transactional Method
@Service
class ProductService(
private val productRepository: ProductRepository
) {
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Lego”, “Earphones”)
@Transactional(propagation = Propagation.REQUIRED)
fun addProduct() {
(1..10).forEach { productId ->
productRepository.saveProduct(”${products.random()}-$productId”)
if (productId == 7) error(”Network error”)
}
}
}
@Repository
class ProductRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional(propagation = Propagation.NOT_SUPPORTED)
fun saveProduct(name: String) {
jdbcTemplate.update(”INSERT INTO products(name) VALUES (?)”, name)
println(”Product with name: $name saved!”)
}
}✅ Case 2: Called from a Non-Transactional Method
@Service
class ProductService(
private val productRepository: ProductRepository
) {
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Lego”, “Earphones”)
fun addProduct() {
(1..10).forEach { productId ->
productRepository.saveProduct(”${products.random()}-$productId”)
if (productId == 7) error(”Network error”)
}
}
}🧪 What Happens in Both Cases?
Regardless of whether addProduct() is transactional or not:
Each call to
saveProduct()executes outside any transactionWhen
productId == 7, an exception is thrownThe first seven inserts are NOT rolled back
All successfully executed inserts are permanently stored in the database
💡 When to Use NOT_SUPPORTED
Use NOT_SUPPORTED when:
You want a method to always run outside of a transaction
You need to avoid transactional rollback on failure
You’re interacting with external systems or services
You’re logging or performing side effects that shouldn’t roll back
🔄 Summary
Runs non-transactionally, even if called inside a transaction
Suspends any existing transaction during method execution
No rollback if an error occurs
Perfect for side-effect operations, logging, or performance-critical code
🧠 Quiz: What Will Be Saved?
Let’s test your understanding of Spring transaction propagation with a real-world scenario.
💡 Scenario:
addProduct()callssaveProduct()inside atry-catchblock.saveProduct()throws an exception only whenid = 3.saveProduct()is annotated with@Transactional(defaultREQUIRED).addProduct()is also annotated with@Transactional(propagation = REQUIRED).
Here’s the full code:
@Service
class ProductService(
private val productRepository: ProductRepository
) {
private val logger: Logger = LoggerFactory.getLogger(ProductService::class.java)
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Earphones”)
@Transactional(propagation = Propagation.REQUIRED)
fun addProduct() {
(1..5L).forEach { productId ->
try {
val name = “${products.random()}-$productId”
productRepository.saveProduct(productId, name)
} catch (ex: Exception) {
logger.error(”Error while adding product with product id: $productId”, ex)
}
}
}
}
@Repository
class ProductRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional
fun saveProduct(id: Long, name: String) {
if (id == 3L) error(”Network error”)
jdbcTemplate.update(”INSERT INTO products(id, name) VALUES (?, ?)”, id, name)
println(”Product with name: $name saved!”)
}
}🤔 What do you expect?
At first glance, it might seem like:
saveProduct()fails only for ID 3The exception is caught inside the
try-catchblockOther products should be saved without issue
But…
🧨 Surprise!
Even though the exception is caught and the loop continues, Spring will throw a final exception at the end of the method:
Product with name: iPhone-1 saved!
Product with name: PlayStation-2 saved!
2025-10-03 22:33:41.685 [main] ERROR c.a.coding.service.ProductService - Error while adding product with product id: 3
Product with name: Lego-4 saved!
Product with name: Earphones-5 saved!
Exception in thread “main” org.springframework.transaction.UnexpectedRollbackException:
Transaction rolled back because it has been marked as rollback-only⚠️ Why does this happen?
Even though the exception is handled, Spring marks the transaction as rollback-only when any participating method ( like saveProduct) throws a RuntimeException.
So:
The whole transaction is considered invalid.
Spring silently rolls it back when the outer method (
addProduct()) completes.And throws
UnexpectedRollbackException— even though everything looked fine inside the loop.
🔁 Transaction Flow Breakdown
addProduct()starts a transaction (REQUIRED).Iterates from
1..5
id = 1, successid = 2, successid = 3, fails and is caught, but marks transaction as rollback-onlyid = 4, success (but will still be rolled back later)id = 5, success (same)
3. At the end of addProduct(), Spring checks the transaction status:
It’s marked as rollback-only → rollback
Throws
UnexpectedRollbackException
✅ Key Takeaways
Catching exceptions inside
try-catchdoes not prevent the transaction from being rolled back.If any method participating in the transaction fails, the entire transaction is doomed, unless special handling ( like
REQUIRES_NEWorNESTED) is used.Spring’s transaction manager marks the current transaction as rollback-only on any uncaught
RuntimeException.
NESTED
The last — and most complex — transaction propagation type in Spring is NESTED.
This propagation behaves similarly to REQUIRED: if there’s no existing transaction, a new one is created. But when a transaction does exist, the nested method doesn’t start a completely new transaction — instead, it creates a savepoint within the existing one.
That means:
If the nested method fails, it can roll back to the savepoint, without affecting the outer transaction.
If the outer transaction fails, everything (including the nested calls) is rolled back.
🧠 In short: NESTED enables partial rollbacks inside a larger transaction.
🔍 How It Works Behind the Scenes
Most relational databases (like PostgreSQL) don’t support true nested transactions. Instead, Spring emulates them using savepoints.
A savepoint is like a “bookmark” inside a transaction. If something goes wrong, you can roll back to that point, without discarding the whole transaction.
📘 Analogy
Think of writing a long document. You save it halfway through (savepoint). If you make a mistake later, you don’t need to start over — you just reload from that saved version.
That’s how NESTED works inside transactions.
✅ Example: Partial Rollback with NESTED
Let’s take the following case:
addProduct()is annotated withREQUIREDIt loops through IDs 1 to 5
It calls
saveProduct(), which is annotated withNESTEDsaveProduct()throws an exception whenid == 3The call is wrapped in a
try-catch, so the loop continues
@Service
class ProductService(
private val productRepository: ProductRepository
) {
private val logger: Logger = LoggerFactory.getLogger(ProductService::class.java)
val products = listOf(”iPhone”, “Lego”, “PlayStation”, “MacBook”, “Earphones”)
@Transactional(propagation = Propagation.REQUIRED)
fun addProduct() {
(1..5L).forEach { productId ->
try {
val name = “${products.random()}-$productId”
productRepository.saveProduct(productId, name)
} catch (ex: Exception) {
logger.error(”Error while adding product with product id: $productId”)
}
}
}
}
@Repository
class ProductRepository(
private val jdbcTemplate: JdbcTemplate,
) {
@Transactional(propagation = Propagation.NESTED)
fun saveProduct(id: Long, name: String) {
if (id == 3L) error(”Network error”)
jdbcTemplate.update(”INSERT INTO products(id, name) VALUES (?, ?)”, id, name)
println(”Product with name: $name saved!”)
}
}🧪 Output:
Product with name: MacBook-1 saved!
Product with name: Lego-2 saved!
2025-10-04 00:18:57.919 [main] ERROR c.a.coding.service.ProductService - Error while adding product with product id: 3
Product with name: Earphones-4 saved!
Product with name: MacBook-5 saved!🔄 What Happened?
Here’s how Spring handles this under the hood:
addProduct()starts a transaction → Transaction AFor each loop iteration,
saveProduct()is called:
For
productId = 1, savepoint SP1 is created → ✅ committedFor
productId = 2, savepoint SP2 → ✅ committedFor
productId = 3, savepoint SP3 → ❌ exception → rollback to SP2For
productId = 4, savepoint SP4 → ✅ committedFor
productId = 5, savepoint SP5 → ✅ committed
3. Finally, Transaction A is committed
Result: Products 1, 2, 4, and 5 are saved. Product 3 is skipped due to error, but nothing else is affected.
💡 Key Takeaways
NESTEDallows fine-grained rollback control.Each nested method execution creates a savepoint, not a new transaction.
If the nested call fails, it rolls back only to its own savepoint.
The outer transaction can still commit unless marked for rollback.
⚠️ Note on Database Support
Not all databases fully support NESTED propagation. For example:
PostgreSQL: ✅ uses savepoints (Spring handles this)
MySQL (InnoDB): ✅ supports savepoints
Others: Behavior may vary
Ensure your database and driver support savepoints properly, or else Spring may fall back to default behavior.
🧪 When Should You Use NESTED?
Use it when:
You want to continue processing even if one step fails
You need partial rollback without affecting the whole transaction
You’re building complex workflows or batch jobs
🔁 Summary
⚡ Quick Recap: Spring Transaction Propagation Levels
Here’s a lightning-fast summary of what each propagation type does:
🔁
REQUIRED– Joins existing transaction or starts a new one if none exists. (Default, safest bet for most business logic)🆕
REQUIRES_NEW– Always suspends current transaction and starts a new one. (Great for logging, notifications, isolated commits)🚫
NEVER– Fails if a transaction exists. (Use when you explicitly want a method to run outside any transaction)🔐
MANDATORY– Requires an active transaction. Throws an error if none exists. (Use for enforcing transaction boundaries)🤝
SUPPORTS– Uses a transaction if one exists; otherwise, runs non-transactionally. (Safe for flexible reads or optional writes)🧩
NOT_SUPPORTED– Always runs non-transactionally, suspends any existing transaction. (Good for performance-critical methods)🪆
NESTED– Creates a savepoint within an existing transaction. Can roll back partially without affecting the outer transaction. (Ideal for complex workflows or partial failure handling)
🔚 Final Summary: Choosing the Right Transaction Propagation
Understanding Spring’s transaction propagation levels is key to designing robust, safe, and performance-optimized applications — especially in areas like finance, e-commerce, or data processing**, where consistency is critical:
🧾 Propagation Behavior at a Glance
🙌 Wrapping Up
Understanding how transaction propagation works — and when to use each type — can make or break your system’s reliability, data integrity, and performance.
Use this guide as your go-to reference next time you’re working on Spring-based applications that deal with sensitive or critical data operations.
If this guide helped clarify Spring’s transaction propagation for you:
⭐ Share it with a fellow developer or your team
💬 Leave a comment or feedback — always appreciated
🔔 Follow for more in-depth posts on Spring, Concurrency, and real-world backend patterns
See you in the next post s— and happy coding! 🚀





