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.

The commit raceView inserts rowinside transactionTask queuedmessage sent nowWorker queriesrow not committedCommit happenstoo late for taskA worker on its own connection cannot see rows the web request has not committed yet.
  1. Queue tasks after commit: transaction.on_commit(lambda: send_confirmation.delay(order.id)).
  2. On Django 5.1+ with Celery 5.4+, use send_confirmation.delay_on_commit(order.id).
  3. Check whether ATOMIC_REQUESTS = True wraps every view in a transaction, which makes the race constant.
  4. If reads go to a replica, read the row from the primary in the task or tolerate lag with a retry.
  5. 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 = True in DATABASES wraps every view.
  • @transaction.atomic on 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.

when the message leaves the web processdelay() inside atomic✕message sent before COMMIT✕worker may not see the row✕rollback still runs the tasktransaction.on_commit✓message sent after COMMIT✓row always visible to worker✓rollback sends nothingon_commit fixes the race and the rolled-back-transaction case togetherdjango-deployment.com
A task queued before commit can also act on data that is later rolled back.

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.

safe task queueing after a writeQueue with on_commit or delay_on_commitnever inside atomicSignal handlers use on_commit toopost_save runs in the transactionTask reads from the primaryreplicas can lagPass primary keys onlyload fresh data in the taskRetrying DoesNotExist as the main fixhides the raceRetries are a backstop for replica lag, not for an uncommitted rowdjango-deployment.com
Fix the ordering first; add a short retry only for replication lag.

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, so on_commit callbacks do not run unless you use self.captureOnCommitCallbacks(execute=True) or TransactionTestCase.
  • 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_commit waits for the outermost transaction, which is what you want.
  • Broker failure after commit. If the broker is down when on_commit fires, the row exists but no task is queued. For critical work, use an outbox table that a periodic task drains.

Next steps

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.

Keep going