Django Transactions: What atomic() Actually Protects
Learn how Django transactions work, what transaction.atomic() actually protects, and how to handle race conditions, row locking, database constraints, nested transactions, and post-commit side effects with select_for_update() and on_commit().
Django
Backend
Python
Sep 13, 2026 · 14 min read
transaction.atomic() is one of the most important tools in Django when multiple database operations must behave as a single unit.
But there is a common misunderstanding:
atomic()does not make your entire operation magically safe.
It gives you database transaction atomicity. It does not automatically prevent race conditions, lock rows, roll back external API calls, or guarantee that background tasks wait for a successful commit.
Understanding these boundaries is essential when building production Django applications.
In this article, we'll look at what transaction.atomic() actually does, how it interacts with row locking and database constraints, how nested transactions work, and how to safely handle external side effects.
What Is a Database Transaction?
A database transaction is a group of operations that the database treats as one unit of work.
Imagine creating an order and its payment record:
order = Order.objects.create(
customer=customer,
total=100,
)
Payment.objects.create(
order=order,
amount=100,
)
What happens if creating the payment fails?
You could end up with:
Order ✓ created
Payment ✗ failed
Now the database contains an order without the payment record that was expected.
A transaction lets us define a boundary around both operations:
from django.db import transaction
with transaction.atomic():
order = Order.objects.create(
customer=customer,
total=100,
)
Payment.objects.create(
order=order,
amount=100,
)
If the block completes successfully, the transaction can be committed.
If an exception causes the atomic block to roll back, the database changes made inside that transaction are rolled back.
The key idea is:
atomic()protects the atomicity of database operations. It does not make an entire Python function, HTTP request, or external system atomic.
Django Uses Autocommit by Default
Django normally runs in autocommit mode.
In practical terms, database operations are generally committed individually unless you explicitly establish a transaction boundary.
For example:
user = User.objects.create(
username="alice",
)
profile = Profile.objects.create(
user=user,
)
These operations are not automatically one indivisible unit simply because they appear next to each other in Python code.
If both operations must succeed or fail together, use an atomic block:
from django.db import transaction
with transaction.atomic():
user = User.objects.create(
username="alice",
)
profile = Profile.objects.create(
user=user,
)
Now both operations participate in the same database transaction.
What Does atomic() Actually Guarantee?
At its core, transaction.atomic() gives you a transaction boundary.
Conceptually:
transaction.atomic()
│
┌────────────┴────────────┐
│ │
Success Failure
│ │
↓ ↓
COMMIT ROLLBACK
For example:
with transaction.atomic():
account.balance -= 100
account.save(update_fields=["balance"])
Transaction.objects.create(
account=account,
amount=-100,
)
If creating the Transaction record raises an exception and the atomic block rolls back, the balance update can be rolled back as well.
This is atomicity.
But atomicity is not the same as concurrency control.
That distinction is extremely important.
atomic() Does Not Automatically Prevent Race Conditions
Consider a simple inventory system.
Suppose:
product.stock = 1
Two customers try to purchase the last item at almost the same time.
A naive implementation might be:
with transaction.atomic():
product = Product.objects.get(pk=product_id)
if product.stock > 0:
product.stock -= 1
product.save(update_fields=["stock"])
It is inside a transaction.
Is it automatically safe?
No.
Two transactions can potentially read the same value before either one updates it:
Request A Request B
--------- ---------
Read stock = 1 Read stock = 1
stock > 0 stock > 0
stock = 0 stock = 0
save() save()
Both requests saw the same state and both decided that inventory was available.
The transaction gave us atomicity, but it did not automatically serialize access to the row.
This is where concurrency control becomes important.
Row Locking With select_for_update()
Django provides select_for_update() for cases where a transaction needs to lock selected database rows.
For example:
from django.db import transaction
with transaction.atomic():
product = (
Product.objects
.select_for_update()
.get(pk=product_id)
)
if product.stock <= 0:
raise OutOfStock()
product.stock -= 1
product.save(update_fields=["stock"])
On databases that support SELECT ... FOR UPDATE, the selected row is locked for the duration of the transaction.
Now the responsibilities are clearer:
atomic()
↓
Defines the transaction boundary
select_for_update()
↓
Locks selected rows during that transaction
These are related, but they solve different problems.
atomic() asks:
Should these database operations succeed or fail together?
select_for_update() asks:
Should this row be protected from concurrent access while I make this decision?
atomic() vs select_for_update()
A useful mental model is:
| Tool | Main responsibility |
|---|---|
transaction.atomic() | Transaction boundary |
select_for_update() | Row-level locking |
| Database constraints | Enforce invariants |
transaction.on_commit() | Delay side effects until commit |
For example, a wallet withdrawal might require both a transaction and row locking:
from django.db import transaction
with transaction.atomic():
account = (
Account.objects
.select_for_update()
.get(pk=account_id)
)
if account.balance < amount:
raise InsufficientFunds()
account.balance -= amount
account.save(update_fields=["balance"])
The transaction groups the changes.
The row lock prevents concurrent transactions from simultaneously making decisions based on the same balance.
A Real Example: Concurrent Withdrawals
Imagine an account has:
Balance = 100
Two requests arrive:
Request A: withdraw 80
Request B: withdraw 50
Without appropriate concurrency control, both requests could read:
balance = 100
Both conclude that there is enough money.
That is a classic race condition.
With row locking:
with transaction.atomic():
account = (
Account.objects
.select_for_update()
.get(pk=account_id)
)
if account.balance < amount:
raise InsufficientFunds()
account.balance -= amount
account.save(update_fields=["balance"])
The second transaction must wait for the first transaction to release the lock, subject to the database and locking configuration.
The critical section is therefore serialized.
Nested atomic() Blocks
Django supports nested atomic() blocks:
with transaction.atomic():
create_order()
with transaction.atomic():
create_payment()
However, nested atomic() blocks do not normally create completely independent database transactions.
The outermost atomic block establishes the main transaction.
Inner atomic blocks generally use savepoints.
Conceptually:
Outer transaction
│
├── create order
│
├── Savepoint
│ │
│ └── create payment
│
└── Commit or rollback
This distinction matters.
A successful inner atomic() block does not mean its changes have already been permanently committed.
The outer transaction can still roll everything back.
Savepoints Are Not Independent Transactions
Consider:
with transaction.atomic():
order = create_order()
with transaction.atomic():
create_invoice()
do_something_else()
The inner block can create a savepoint.
If an error occurs inside that inner block and the exception is handled around the appropriate boundary, Django can roll back to the savepoint while allowing the outer transaction to continue.
Conceptually:
One database transaction
│
├── Savepoint
├── Work
├── Roll back to savepoint if necessary
└── Final commit
It is not:
Transaction A
Transaction B
The distinction becomes especially important when designing error handling.
Be Careful With IntegrityError
One of the most important transaction rules in Django is:
Don't catch a database error inside an atomic block and blindly continue using the same transaction.
For example:
with transaction.atomic():
try:
create_payment()
except IntegrityError:
pass
create_order()
After a database error such as IntegrityError, Django may mark the transaction as needing rollback.
Continuing to perform queries in that broken transaction can result in:
TransactionManagementError
A safer pattern is to put the atomic boundary around the operation whose failure you want to handle:
try:
with transaction.atomic():
create_payment()
except IntegrityError:
handle_payment_error()
Now the exception leaves the atomic block, allowing Django to roll back that transaction appropriately.
The placement of try and atomic() is therefore important.
atomic() Cannot Roll Back External Systems
This is one of the most important limitations of database transactions.
Consider:
with transaction.atomic():
order = create_order()
charge_credit_card()
Suppose the payment provider successfully charges the customer.
Then a later database operation fails.
Django can roll back the database transaction.
But it cannot automatically undo the payment provider's successful HTTP request.
You now have two different systems:
Your Database
│
│ HTTP
↓
Payment Provider
The database transaction controls the database.
It does not automatically control:
- Payment gateways
- HTTP APIs
- Email providers
- SMS providers
- File storage
- External databases
- Other services
A database rollback cannot magically roll back an external side effect.
Use transaction.on_commit() for Side Effects
Django provides transaction.on_commit() for callbacks that should run only after a successful transaction commit.
Instead of:
with transaction.atomic():
order = create_order()
send_notification(order)
you can use:
from django.db import transaction
with transaction.atomic():
order = create_order()
transaction.on_commit(
lambda: send_notification(order)
)
The callback runs after the transaction successfully commits.
If the transaction rolls back, the callback is not executed.
This is particularly useful for background tasks.
For example:
with transaction.atomic():
order = create_order()
transaction.on_commit(
lambda: process_order.delay(order.pk)
)
Without on_commit(), a Celery worker could start processing the task before the transaction creating the order has committed.
That can lead to subtle problems where the worker cannot see the expected database state.
Don't Put Slow Network Calls Inside Transactions
Avoid this pattern:
with transaction.atomic():
order = create_order()
response = requests.post(
payment_url,
json={"amount": order.total},
)
create_payment_record(response)
The problem is that the database transaction remains open while waiting for an external system.
The network request might take:
50 ms
500 ms
5 seconds
30 seconds
During that time, the database transaction remains open.
Depending on the workload, this can increase lock contention, hold database resources longer, and reduce throughput.
A better architecture is usually:
Short database transaction
↓
Commit
↓
External operation
↓
Retry / reconcile / update state
For operations such as payments, webhooks, and other unreliable external services, you will often also need:
- Explicit state transitions
- Idempotency
- Retries
- Timeouts
- Reconciliation
- Unique constraints
A transaction is only one part of the design.
Database Constraints Still Matter
A transaction is not a replacement for database constraints.
Suppose an email address must be unique.
The database should enforce that rule:
class User(models.Model):
email = models.EmailField(unique=True)
This is unsafe as the only protection:
if not User.objects.filter(email=email).exists():
User.objects.create(email=email)
Two concurrent requests can both execute the exists() query before either creates the user.
Both might observe:
User does not exist
and then both attempt to create the record.
A database-level unique constraint provides the final guarantee.
This is why production systems commonly combine:
Transactions
+
Database constraints
+
Concurrency control
Each one protects a different aspect of consistency.
Atomic Updates Can Also Help
Not every concurrency problem requires select_for_update().
Sometimes the safest solution is to let the database perform the update atomically.
For example:
from django.db.models import F
updated = (
Product.objects
.filter(
pk=product_id,
stock__gt=0,
)
.update(stock=F("stock") - 1)
)
if updated == 0:
raise OutOfStock()
Here, the database performs the conditional update as a single statement.
This can be preferable to:
SELECT
↓
Python decision
↓
UPDATE
because the condition and update are handled together by the database.
The right approach depends on the invariant and the database operation you need.
ATOMIC_REQUESTS
Django also supports:
ATOMIC_REQUESTS = True
This wraps each view in a transaction for the configured database.
It can be convenient, but it does not mean that literally everything related to the HTTP request becomes transactional.
For example, middleware runs outside the view transaction, and response handling has its own boundaries.
There is also a performance consideration.
Wrapping every request in a transaction can create unnecessary transaction overhead and may keep transactions open longer than necessary.
For that reason:
Prefer meaningful, short transaction boundaries over making every request transactional by default.
A Production Order Workflow
Consider an order system where inventory and payment records must remain consistent.
A reasonable database transaction might look like:
from django.db import transaction
with transaction.atomic():
product = (
Product.objects
.select_for_update()
.get(pk=product_id)
)
if product.stock < quantity:
raise OutOfStock()
product.stock -= quantity
product.save(update_fields=["stock"])
order = Order.objects.create(
customer=customer,
product=product,
quantity=quantity,
)
Payment.objects.create(
order=order,
amount=order.total,
status="pending",
)
transaction.on_commit(
lambda: notify_order_created.delay(order.pk)
)
Notice that each tool has a specific responsibility:
atomic()
↓
Keeps database changes together
select_for_update()
↓
Protects inventory from concurrent updates
Database constraints
↓
Protects business invariants
on_commit()
↓
Delays the background side effect until commit
The important part is not simply using more transaction-related tools.
It is using the right tool for the right problem.
Common Mistakes
1. Assuming atomic() Prevents Race Conditions
It doesn't automatically lock every row you read.
If concurrent requests can modify the same shared state, consider:
select_for_update()F()expressions- Database constraints
- Optimistic concurrency
- Appropriate isolation levels
depending on the problem.
2. Making Transactions Too Large
Avoid putting slow operations inside transactions.
Long-running transactions can increase contention and resource usage.
3. Calling External APIs Inside Transactions
A database rollback cannot undo an HTTP request that already succeeded.
4. Starting Celery Tasks Before Commit
A worker can run before the transaction has committed.
Use:
transaction.on_commit(
lambda: task.delay(object.pk)
)
when the task depends on committed database state.
5. Catching Database Errors Inside atomic()
Don't hide an IntegrityError and continue blindly inside the same broken transaction.
Structure exception handling around the atomic boundary.
6. Using Transactions Without Constraints
Transactions don't replace:
UNIQUECHECK- Foreign keys
- Other database-level invariants
When Should You Use atomic()?
Use transaction.atomic() when multiple database changes represent one logical operation.
Typical examples include:
- Creating an order and related records
- Transferring money between accounts
- Updating inventory and creating a reservation
- Creating payment and ledger records
- Updating several related models consistently
- Performing a multi-step state transition
You don't necessarily need an explicit transaction around every database query.
For a single independent database operation, adding an atomic block may provide little value.
The goal is to define a meaningful transaction boundary.
A Practical Decision Checklist
Before adding transaction.atomic(), ask:
1. Which database changes must succeed together?
2. What happens if a later step fails?
3. Can two requests modify the same data concurrently?
4. Do I need row locking?
5. Can the operation be expressed as an atomic database update?
6. Should the database enforce a constraint?
7. Am I performing network or slow work inside the transaction?
8. Should a Celery task wait until commit?
9. Where should database exceptions be caught?
10. How long will this transaction remain open?
These questions are more useful than simply adding @transaction.atomic to every service function.
The Mental Model to Remember
Instead of thinking:
atomic() = make everything safe
think:
transaction.atomic()
↓
Define a database transaction boundary
↓
Commit together
OR
Rollback together
Then add other mechanisms when the problem requires them:
┌──────────────────────────────┐
│ Database Consistency │
└──────────────┬───────────────┘
│
┌────────┼────────┐
↓ ↓ ↓
atomic() Locks Constraints
│ │ │
Transaction Race Invariants
boundary control
│
↓
on_commit()
│
↓
External side effects
Each mechanism has a different responsibility.
Final Takeaway
transaction.atomic() is powerful, but it is not a magic safety switch.
Its primary responsibility is database transaction atomicity:
Database changes succeed
OR
Database changes roll back
It does not automatically:
- Prevent race conditions
- Lock every row you access
- Roll back external API calls
- Make network operations transactional
- Replace database constraints
- Make long-running operations safe
- Guarantee that Celery runs after commit
Production-grade transaction handling usually combines several mechanisms:
transaction.atomic()
↓
Transaction boundary
select_for_update() / F()
↓
Concurrency control
Database constraints
↓
Invariant enforcement
transaction.on_commit()
↓
Post-commit side effects
The goal is not to put atomic() everywhere.
The goal is to define the right transaction boundary, protect shared state from concurrency problems, enforce invariants at the database level, and keep external side effects outside the transaction whenever possible.
Once you understand what each tool actually protects, Django transaction handling becomes much easier to reason about—and much safer to use in production.
Persian Version
The Persian version of this article is available on Virgool:
Related Notes
The Hidden Cost of Django REST Framework Serializers
Learn how Django REST Framework serializers can cause N+1 queries, slow API responses, and unnecessary database work—and how to optimize them with select_related, prefetch_related, annotations, and better serializer design.
Building a Django Package — Part 4: Publishing django-healthkit to PyPI
In this final part, we prepare django-healthkit for release, build and validate the package, test it on TestPyPI, publish it to PyPI, and create a Git tag and GitHub release.
Building a Django Package — Part 3: Health Check Manager and Endpoint
In Part 3, we connect the database and cache health checks, build the health check manager, expose a Django health endpoint, measure check latency, and return a structured health status.
Building a Django Package — Part 2: Database and Cache Health Checks
In this part, we implement the first health checks for django-healthkit, covering database connectivity and Django cache functionality with simple, independent, and testable checks.
Building a Django Package — Part 1: Setting Up django-healthkit
In this part, we build the initial structure of django-healthkit, configure the package with pyproject.toml, and prepare it for development.
Building a Secure Webhook Receiver for Server-to-Server Communication
How I built a secure FastAPI webhook receiver using RSA signatures to enable authenticated server-to-server communication, proxy requests, and connect applications across different network environments.
Why I Switched to Conventional Git Commit Messages
Why I switched from inconsistent Git commit messages to a Conventional Commits style, with practical examples for cleaner and more maintainable Git history.
Building django-healthkit
How a simple health endpoint for Bidar turned into django-healthkit, a lightweight Django health-check package.
Designing a Gold Jewelry E-Commerce Database with Django
Learn how to design a scalable Django database for a gold jewelry e-commerce platform by modeling products, attributes, and purchasable product variants using real-world domain-driven design principles.
How to Fix Common Next.js 16 Build Errors (Proxy, Dynamic Rendering & Revalidation)
Learn how to fix common Next.js 16 build errors, understand Proxy, Dynamic Rendering, and Revalidation, and improve your application's SEO and performance.
Why I Built cyber-ui: A Minimal Design System for Developers
How my projects, from backend systems to monitoring tools, led me to create a minimal and personal UI foundation.