> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Django on_commit Ran Early? Check the Database Alias
- URL: https://debugly.dev/django-on-commit-wrong-database-alias/
- Published: 2026-11-05T18:15:00.000Z
- Updated: 2026-11-05T18:15:00.000Z
- Description: 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.
- Author: Rohit Bhadani
- Tags: Django, Python, Backend, Debugging

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*](https://debugly.dev/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](https://docs.djangoproject.com/en/6.1/topics/db/multi-db/?ref=debugly.dev#manually-selecting-a-database) covers explicit ORM selection; the [transaction guide](https://docs.djangoproject.com/en/6.1/topics/db/transactions/?ref=debugly.dev#performing-actions-after-commit) 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](https://raw.githubusercontent.com/celery/celery/v5.6.3/celery/contrib/django/task.py?ref=debugly.dev) 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:

| Scenario                                                                                 | What to assert                                                                                                 |
| ---------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| orders is open; default is in autocommit                                                 | The correctly bound callback does not publish inside the block. The omitted-alias variant calls the spy early. |
| The outer orders block commits                                                           | The 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 back | The callback registered in the discarded transaction or savepoint does not publish.                            |

Use [captureOnCommitCallbacks(using="orders")](https://docs.djangoproject.com/en/6.1/topics/testing/tools/?ref=debugly.dev#django.test.TestCase.captureOnCommitCallbacks) 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](https://docs.djangoproject.com/en/6.1/topics/db/multi-db/?ref=debugly.dev#an-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](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html?ref=debugly.dev) 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](https://debugly.dev/webhook-idempotency-duplicate-delivery/) 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.