skip to content

In Django, a post_save receiver calls an external API and raises; what happens to the save and the request, and how do send() and send_robust() differ?

level: seniorimportance: should knowfreq 46%

answer

  1. same thread, before save() returns
  2. exceptions propagate
  3. outer atomic block decides rollback
  4. built-in signals always use send()

basics

~20 s

The exception propagates out of save() into the view. Inside an atomic block the row is rolled back; in autocommit the row is already written. send() lets errors escape; send_robust() catches and returns them, but only for signals you send yourself.

solid answer

~40 s

`post_save` is dispatched with `Signal.send()` at the end of `save()`, in the caller's thread, so the API call adds its latency to the request and its exception escapes `save()`. If the save ran inside `transaction.atomic()` or with `ATOMIC_REQUESTS`, the exception leaving the block rolls the row back; in plain autocommit the `INSERT` already committed, so the caller gets a 500 for a row that exists. `send()` stops at the first failing receiver, so later receivers are skipped. `send_robust()` catches `Exception` subclasses, logs them to `django.dispatch` and returns them as responses, but Django's own model signals always use `send()`. So I keep network calls out of receivers, or wrap them defensively and defer them until after commit.

code

python · 10 lines
python
from django.dispatch import Signal

order_shipped = Signal()


def ship(order):
    order.mark_shipped()
    results = order_shipped.send_robust(sender=type(order), order=order)
    failed = [r for r, resp in results if isinstance(resp, Exception)]
    return failed

go deeper

for a junior

Recall that a receiver runs inside save(), in the same thread, so its exception reaches the view like any other error.

for a middle

Explain send() versus send_robust(): propagation and a stopped loop versus caught, logged and returned exceptions, and who calls which.

for a senior

Reason about the transaction around save(): autocommit leaves a committed row behind a 500, atomic rolls back valid work, and external calls must wait for commit.

for a principal

Set the rule that receivers never perform network I/O inline, and decide between after-commit callbacks and a queue with retries based on how much loss is acceptable.

## Receivers are plain function calls Django's dispatcher has no queue and no worker. `Signal.send(sender, **kwargs)` loops over the connected **synchronous receivers** and calls each one, in the same thread, before returning to the code that sent the signal. For `post_save`, the sender is `Model.save()` itself, so the receiver runs **before `save()` returns**. Consequences: - A slow receiver makes every `save()` slow — an HTTP call in a receiver adds its full latency to the request. - An exception raised by the receiver comes **out of `save()`**, then out of the view, and becomes a 500 unless something catches it. - `send()` does not catch anything, so **receivers connected after the failing one never run** for that event. ## What happens to the row Whether the saved row survives depends on the transaction around the `save()` call, not on the signal: | Situation | Row after the receiver raises | |---|---| | Autocommit, no outer `atomic` | Already committed; the error surfaces anyway | | `save()` inside `transaction.atomic()` and the error leaves the block | Rolled back with the rest of the block | | `ATOMIC_REQUESTS = True` for the database | The whole view's transaction rolls back | | Error caught inside the `atomic` block | The row stays; the block still commits | The first row is the nasty one: the user sees an error page, retries, and creates a duplicate. The second and third rows are the opposite surprise: an unrelated email-API outage rolls back a perfectly valid order. The mirror image also matters: if the receiver *succeeds* inside a transaction that later rolls back, the external API has already been called about a row that no longer exists. That is why side effects leaving the process belong after commit (the `transaction.on_commit()` hook, covered with transactions) rather than in the receiver body. ## send() vs send_robust() vs the async pair | Method | Receiver exceptions | Returns | |---|---|---| | `send()` | Propagate; dispatch stops | `[(receiver, response), ...]` | | `send_robust()` | Caught (`Exception` subclasses), logged to the `django.dispatch` logger | The exception instance in place of the response | | `await asend()` (5.0+) | Propagate, like `send()` | Same list | | `await asend_robust()` (5.0+) | Caught, like `send_robust()` | Same list | Two facts interviewers probe: 1. **You cannot choose `send_robust()` for built-in signals.** The Django signal reference states that all built-in signals are sent with `send()` (the async request-response cycle uses `asend()`). `send_robust()` is for **custom signals your own code sends**. 2. `send_robust()` swallows the error for the caller but does not hide it: each failure is logged at error level with the traceback, and the exception is available on `__traceback__` of the returned instance. ## Async receivers Since Django 5.0 a receiver may be an `async def`. With `send()`, all sync receivers run first, then the async ones run together through one `async_to_sync()` call; with `asend()`, sync receivers are wrapped with `sync_to_async()`. Async receivers run **concurrently** — through `asyncio.TaskGroup` since Django 6.1, `asyncio.gather()` before — so an async receiver registered before a sync one may run after it, and receivers must not rely on each other's order. ## Making the receiver safe ```python import logging from django.db import transaction from django.db.models.signals import post_save from django.dispatch import receiver from .crm import push_customer from .models import Customer logger = logging.getLogger(__name__) @receiver(post_save, sender=Customer) def sync_to_crm(sender, instance, created, **kwargs): def push(): try: push_customer(instance.pk) except Exception: logger.exception("CRM push failed for customer %s", instance.pk) transaction.on_commit(push) ``` - The network call runs only if the surrounding transaction commits. - A CRM failure is logged instead of turning a committed save into a 500. - For real reliability, hand the work to a task queue with retries instead of calling the API in-process.

  • Why does send_robust() not help with a failing post_save receiver?
    Because you do not call `send()` for `post_save`; `Model.save()` does, and Django sends all built-in signals with `send()`. To make a `post_save` receiver non-fatal you catch exceptions inside the receiver itself, or you move the risky work somewhere that has its own error handling, such as an after-commit callback or a queued task.
  • In Django 5.0+, how are async receivers run when the signal is sent with plain send()?
    Synchronous receivers run first, in order. Async receivers are then grouped and run concurrently inside a single `async_to_sync()` call, through `asyncio.TaskGroup` in Django 6.1. So registration order is not execution order across the sync/async split, and there is a small adaptation cost.
  • The receiver succeeds but the view later raises inside ATOMIC_REQUESTS. What went wrong?
    The row is rolled back, but the receiver's API call already happened, so the external system now refers to a customer that does not exist. Anything that leaves the database, such as an HTTP call or an email, should run only after commit, not in the receiver body.

A post_save receiver is a colleague who must initial your form before you leave the counter: you wait for them, and if they tear it up, you leave with an error. Whether the form was already filed depends on whether the clerk had a file open (an atomic block) or filed it at once (autocommit).

saying these in an interview costs you the question

  • Thinks post_save receivers run asynchronously after the response is sent
  • Believes send() calls every receiver even when an earlier one raises
  • Assumes Django sends post_save with send_robust() so failures are harmless
  • Says a receiver exception never affects the row because post_save is after the write
  • Relies on async receivers running in registration order