You have seen this bug before. Maybe you wrote it yourself.
A user places an order. Your view creates the order, sends a confirmation email, and returns a success response. Everything looks fine.
Then the database throws an error halfway through. The transaction rolls back. The order never exists in the database.
But the email already went out.
The customer gets a confirmation for an order that does not exist. Your support inbox fills up. You spend an hour debugging something that should never have happened.
This is one of the most common silent bugs in Django projects. And most developers do not catch it until it hits production.
Why This Happens
Django wraps database operations in transactions. When something goes wrong, the transaction rolls back and none of the changes are saved.
But side effects — emails, webhooks, push notifications, third-party API calls — are not part of the transaction. They fire immediately when you call them. Django has no way to roll them back.
Here is the typical code that causes this:
from django.db import transaction
from django.core.mail import send_mail
from shop.models import Order
def create_order(user, cart):
with transaction.atomic():
order = Order.objects.create(user=user, total=cart.total)
for item in cart.items:
order.items.create(product=item.product, quantity=item.quantity)
# This fires immediately — before the transaction commits
send_mail(
subject="Your order is confirmed",
message=f"Order #{order.id} has been placed.",
from_email="noreply@shop.com",
recipient_list=[user.email],
)
# If anything fails after this line the order is rolled back
# but the email is already sent
process_payment(order)
If process_payment raises an exception, the order disappears from the database. The email does not.
The Wrong Fix
Most developers fix this by moving the email outside the transaction block:
def create_order(user, cart):
with transaction.atomic():
order = Order.objects.create(user=user, total=cart.total)
for item in cart.items:
order.items.create(product=item.product, quantity=item.quantity)
process_payment(order)
# Outside the transaction — runs after commit, right?
send_mail(
subject="Your order is confirmed",
message=f"Order #{order.id} has been placed.",
from_email="noreply@shop.com",
recipient_list=[user.email],
)
This looks correct. But it is still broken.
If your create_order function is called from inside another transaction.atomic() block — which is common in Django — the inner block does not actually commit when it exits. The outer transaction is still open. Your email fires before the data is safe.
You cannot reliably know from inside a function whether you are in a nested transaction or not.
The Right Fix — run_after_commit
Django provides transaction.on_commit() for exactly this problem. It runs a callback only after the outermost transaction successfully commits to the database.
Here is a clean utility that wraps it:
# utils/services.py
from django.db import transaction
def run_after_commit(func):
"""
Defer a function call until the current database transaction commits.
If there is no active transaction, the function runs immediately.
Safe to use inside nested transactions.
"""
transaction.on_commit(func)
Now update the order creation code:
from django.core.mail import send_mail
from django.db import transaction
from utils.services import run_after_commit
from shop.models import Order
def create_order(user, cart):
with transaction.atomic():
order = Order.objects.create(user=user, total=cart.total)
for item in cart.items:
order.items.create(
product=item.product,
quantity=item.quantity,
price=item.product.price,
)
process_payment(order)
# Deferred — only runs if the entire transaction commits successfully
run_after_commit(lambda: send_mail(
subject="Your order is confirmed",
message=f"Order #{order.id} has been placed.",
from_email="noreply@shop.com",
recipient_list=[user.email],
))
Now the email only fires if the transaction commits. If anything fails and the transaction rolls back, the callback never runs. No phantom emails.
Why This Matters Beyond Emails
run_after_commit is useful for any side effect that should only happen when data is safely persisted:
Webhooks
run_after_commit(lambda: send_webhook(
event="order.created",
payload={"order_id": order.id, "total": str(order.total)}
))
Cache invalidation
run_after_commit(lambda: cache.delete(f"user_orders_{user.id}"))
Search index updates
run_after_commit(lambda: update_search_index(order.id))
Push notifications
run_after_commit(lambda: notify_user(
user_id=user.id,
message=f"Your order #{order.id} is confirmed."
))
All of these should only happen after the data exists in the database. run_after_commit makes that guarantee with one line.
One Thing to Know
transaction.on_commit() only works inside a transaction. If you call it outside any transaction block it runs immediately — which is the correct behavior since there is nothing to wait for.
This means the utility is safe to use anywhere in your codebase without checking whether you are inside a transaction or not.
Testing This
In tests Django wraps each test in a transaction that never commits by default. This means on_commit callbacks never fire in normal tests.
To test code that uses run_after_commit you have two options:
# Option 1 — use TransactionTestCase
from django.test import TransactionTestCase
class OrderTestCase(TransactionTestCase):
def test_email_sent_after_order(self):
# on_commit fires here because TransactionTestCase
# actually commits transactions
...
# Option 2 — use captureOnCommitCallbacks (Django 4.1+)
from django.test import TestCase
class OrderTestCase(TestCase):
def test_email_sent_after_order(self):
with self.captureOnCommitCallbacks(execute=True):
create_order(self.user, self.cart)
self.assertEqual(len(mail.outbox), 1)
captureOnCommitCallbacks is the cleaner option for most test suites.
This Is One of 99
run_after_commit is one utility from Django 99 Utilities — a practical reference book covering 99 production-ready patterns across 15 categories including queries, models, services, security, caching, and more.
Every utility follows the same format: the real problem, the solution, clean production-ready code, a usage example with full imports, and why it matters.
The book also includes a GitHub starter project with seeded data so you can test every utility immediately without setting anything up from scratch.
More Django content: django.wiki
Ahmad is a Django developer with 7+ years of experience and the creator of django.wiki — practical Django knowledge for working developers.
Top comments (0)