Fix Celery Tasks Raising DoesNotExist for Newly Created Objects
Fix Celery tasks that raise Model.DoesNotExist for an object the view just created: queue tasks with transaction.on_commit, understand ATOMIC_REQUESTS, read replicas, and pass IDs instead of model instances.
Problem statement
A view creates an order and queues a task to send the confirmation:
order = Order.objects.create(...)
send_confirmation.delay(order.id)
Most of the time it works. Under load, or in production but never locally, the worker fails with:
orders.models.Order.DoesNotExist: Order matching query does not exist.
Yet the order is clearly in the database a second later. The task is running before the transaction that created the row has committed. The worker uses its own database connection, and until the web request's transaction commits, that row is invisible to every other connection.
- Queue tasks after commit:
transaction.on_commit(lambda: send_confirmation.delay(order.id)). - On Django 5.1+ with Celery 5.4+, use
send_confirmation.delay_on_commit(order.id). - Check whether
ATOMIC_REQUESTS = Truewraps every view in a transaction, which makes the race constant. - If reads go to a replica, read the row from the primary in the task or tolerate lag with a retry.
- Pass primary keys to tasks, never model instances.
Step-by-step solution
1. Find where the transaction is
The race exists whenever delay() runs inside a transaction:
ATOMIC_REQUESTS = TrueinDATABASESwraps every view.@transaction.atomicon the view or a service function.with transaction.atomic():blocks.- Signal handlers such as
post_save, which run inside the caller's transaction.
Locally the worker is often slower to pick up the message than the request is to finish, so the bug hides. In production, with idle workers and a fast broker, the task wins the race.
2. Queue the task on commit
Django's on_commit runs a callback after the outermost transaction commits, and never if it rolls back:
from django.db import transaction
def checkout(request):
with transaction.atomic():
order = Order.objects.create(user=request.user, total=cart.total)
order.lines.bulk_create(cart.lines_for(order))
transaction.on_commit(lambda: send_confirmation.delay(order.id))
return redirect("order-done", order.id)
Celery 5.4 adds a shortcut that does the same thing:
send_confirmation.delay_on_commit(order.id)
Outside a transaction, on_commit runs the callback immediately, so it is safe to use everywhere.
3. Fix signal handlers
A post_save handler that queues a task has the same bug, and it is harder to spot:
@receiver(post_save, sender=Order)
def order_saved(sender, instance, created, **kwargs):
if created:
transaction.on_commit(lambda: send_confirmation.delay(instance.pk))
Be careful with lambdas in loops: bind the value as a default argument (lambda pk=obj.pk: task.delay(pk)) so each callback uses its own ID.
4. Handle read replicas
If a database router sends reads to a replica, the task can run after commit and still miss the row because the replica lags. Read from the primary for work that follows a write:
@shared_task(bind=True, max_retries=5)
def send_confirmation(self, order_id):
try:
order = Order.objects.using("default").get(pk=order_id)
except Order.DoesNotExist as exc:
raise self.retry(exc=exc, countdown=2)
...
The retry is a safety net for rare lag, not a replacement for on_commit.
5. Pass IDs, not instances
send_confirmation.delay(order) # avoid
send_confirmation.delay(order.pk) # good
A pickled instance carries the state from when it was queued. The task then works with stale data, and pickle serialization is unsafe and disabled by default. Load the row fresh in the task.
Verification check: reproduce under load with idle workers, for example by creating fifty orders in a loop from a shell inside one transaction.atomic() block with on_commit. Every task should find its row. Search worker logs for DoesNotExist after a day of production traffic.
Explanation
PostgreSQL and MySQL (with InnoDB at the default isolation level) only show committed data to other connections. The web request inserts inside a transaction, sends a message to Redis immediately, and commits a few milliseconds later when the view returns. The worker connection queries in those milliseconds and sees nothing. on_commit simply moves the publish after the commit. It also handles the opposite failure: a transaction that rolls back after queueing would otherwise let the task act on data that never existed.
Edge cases / notes
- Tests. In
TestCase, transactions never commit, soon_commitcallbacks do not run unless you useself.captureOnCommitCallbacks(execute=True)orTransactionTestCase. - Eager mode. With
CELERY_TASK_ALWAYS_EAGER, tasks run inline and never show the race, which is one reason it appears only in production. - Nested atomics.
on_commitwaits for the outermost transaction, which is what you want. - Broker failure after commit. If the broker is down when
on_commitfires, the row exists but no task is queued. For critical work, use an outbox table that a periodic task drains.
Next steps
- Make the task safe to run more than once with Make Celery Tasks Idempotent and Safe to Retry.
- If you route reads to replicas, review Django Database Read Replicas.
FAQ
Why does it work locally but fail in production?
Locally the request usually commits before the worker picks up the message. In production idle workers are faster than the commit.
Does ATOMIC_REQUESTS cause this?
It makes every view a transaction, so any delay() in a view has the race. Use on_commit regardless.
Should I add a countdown instead?
A countdown only makes the race less likely. on_commit removes it.
What is delay_on_commit?
A Celery 5.4 shortcut that calls transaction.on_commit for you before sending the task.