Django on_commit Ran Early? Check the Database Alias

A Django task can run early when its on_commit hook belongs to the wrong database. Trace the write, the callback and the worker’s read.

Share
Two orange databases: one gate is closed while the other releases a task envelope.

The worker says the order doesn't exist. You look at the request code and find on_commit() exactly where you'd expect it. A moment later the order is in the database. So what did the worker know that the callback didn't?

In a multi-database Django app, check the alias passed to on_commit() before blaming the queue. The hook belongs to one database connection. If the write is inside atomic(using="orders") but the callback is registered without using, it uses the default connection. If that connection is in autocommit with no open transaction, the callback runs immediately, even while the order is still uncommitted.

This is an AI-prepared, documentation-based explainer, not a personally reproduced incident. The example and verification steps are illustrative and were not executed. See Debugly's editorial policy.

Follow the write before following the task

Imagine a service that writes orders through an alias called orders and keeps its other models on default. The request creates an order and asks a worker to index it. The queue may be working perfectly. It just received its instructions before the row was visible to a different database connection.

# Illustrative: Order and index_order are application objects.
from functools import partial
from django.db import transaction

with transaction.atomic(using="orders"):
    order = Order.objects.using("orders").create(reference=reference)
    transaction.on_commit(partial(index_order.delay, order.pk))
    # This hook uses "default", not "orders".

atomic() doesn't redirect the ORM, and a router's write decision doesn't redirect transaction hooks. Here the explicit .using("orders") makes the write's destination clear. The callback has a separate choice to make. Django's multiple-database guide covers explicit ORM selection; the transaction guide covers the callback's timing.

There are two ways for this to go wrong. If default is in autocommit, the callback doesn't wait. If default is in its own atomic block, the callback waits for that transaction, which might commit earlier, commit later, or roll back independently. A pair of transactions doesn't become a committee just because they're in the same request.

A useful first diagnostic is to record the write alias, the hook alias, and when the broker publish starts relative to the write transaction's exit. In a controlled reproduction, transaction.get_autocommit(using=...) helps identify the immediate path. Don't log order contents. If the broker publish happened after the right commit, stop investigating early dispatch and check the worker's database route instead.

Bind the callback to the connection that owns the row

# Illustrative: the task accepts db_alias and uses it for its read.
db = "orders"
with transaction.atomic(using=db):
    order = Order.objects.using(db).create(reference=reference)
    transaction.on_commit(
        partial(index_order.delay, order.pk, db_alias=db),
        using=db,
    )

There are two alias arguments here on purpose. The outer using=db tells Django which connection owns the hook. The inner db_alias=db is an application argument for the task, which can use Order.objects.using(db_alias).get(pk=order_id) to read the writer. Use a trusted alias from your own routing logic, not a string taken from the request. Capture the ID and alias at registration rather than closing over a variable that might change later.

If you're using Celery, check your installed helper before assuming it can select the hook's connection. The Celery 5.6.3 implementation of delay_on_commit() forwards its arguments to delay() and registers the hook without passing a database alias to Django. Adding using="orders" to that helper call would pass a task argument in that version. Use the explicit Django hook when you need to select the connection.

Use a rollback to make the difference obvious

A successful request can hide a bad hook: both databases eventually commit, the worker happens to run late enough, and everyone goes home. A rollback gives you a sharper test. In a multi-database TransactionTestCase with both aliases enabled, replace the task's publish call with a spy and exercise these cases:

ScenarioWhat to assert
orders is open; default is in autocommitThe correctly bound callback does not publish inside the block. The omitted-alias variant calls the spy early.
The outer orders block commitsThe correctly bound callback publishes after the transaction exits, with the intended ID and read alias.
The outer block rolls back, or an inner savepoint containing the registration rolls backThe callback registered in the discarded transaction or savepoint does not publish.

Use captureOnCommitCallbacks(using="orders") when you only need to assert registration on the right connection. Executing captured callbacks in a regular TestCase simulates callback execution; it doesn't establish when another connection can see the committed row. For that part, use a real commit test against the relevant database behavior.

A committed row still doesn't guarantee delivery

After you fix the hook alias, a worker reading an asynchronously replicated database can still miss the row. Django's primary/replica example explicitly leaves replication lag unsolved. For a task that needs the row immediately, make the read route an explicit part of the design.

There's a separate gap between the database commit and a successful broker publish. The process can exit or the broker call can fail after the row is committed. The callback won't undo that commit. If losing the notification is unacceptable, consider recording a durable outbox entry in the same write transaction and having a relay publish it with retries. Design the consumer for duplicate deliveries; a relay can lose the acknowledgement after the broker accepted the message. Debugly's duplicate-delivery explainer covers the same consumer-side principle.

In the next code review, check three things together: where the row is written, which connection owns the callback, and where the worker reads. If any of those is implicit, a passing test against one database may be answering a much easier question than production is asking.