For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Chimeras

Why does my Django signal break after a refactor?

Chimera CH-01 · Django

Rules it tests: AR-07, AR-08, AR-25, AR-31

An order's status field decides which handler runs: a post_save receiver builds the function name from the status and calls it through getattr. Tests that call the handlers directly keep passing when a handler's signature changes, because the call that went wrong is a string-built name that no test and no type checker connects to the handler. Replace the lookup with an explicit match and the call is an ordinary call again.

The code below is from a small example project we wrote for this page. The break below is read from the files, by line.

1. The setup

Django does the heavy lifting well here. post_save fires after every Order.save(), the signal machinery is decoupled from the view that saved the order, and putting side effects in handlers keeps views thin. The shape is common and reasonable.

orders/models.py has an Order with a status field (draft, placed, paid, shipped, refunded). orders/handlers.py has one function per status:

python# before/orders/handlers.py def on_placed(order: Order) -> None: ... def on_paid(order: Order) -> None: ... def on_shipped(order: Order) -> None: ... def on_refunded(order: Order, reason: str) -> None: ... # line 37

orders/signals.py picks one:

python# before/orders/signals.py @receiver(post_save) def dispatch_order_status(sender, instance, **kwargs): if not isinstance(instance, Order): return handler = getattr(handlers, f"on_{instance.status}", None) # line 12 if handler is not None: handler(instance) # line 14

Two test files cover it. orders/tests/test_handlers.py calls each handler directly. orders/tests/test_signals.py saves orders in the placed and shipped states and checks the mail outbox.

2. Where the call path hides

Three things hide the path from a coding agent or a static tool:

  • The function name is a string. Line 12 builds "on_refunded" at runtime from a database value. Searching for callers of on_refunded finds the definition (handlers.py:37) and the direct test call (test_handlers.py:27). Nothing in the source says signals.py reaches it.
  • The call is through a variable. Line 14 calls handler(...). handler came from getattr with a default of None, so a type checker sees an object of unknown type or None, not a function with a signature to compare against.
  • **The receiver has no sender.** @receiver(post_save) connects to every model's save. The isinstance check on line 10 filters at runtime, so a reader can't tell from the decorator which model this serves. The module is also loaded as a side effect of the import at the bottom of models.py, not from AppConfig.ready(), so a tool that follows INSTALLED_APPS to apps.py finds no mention of it.

The None default turns a second kind of edit into a silent one: rename on_shipped to on_dispatched and the dispatcher skips shipped orders without an error.

3. The edit that breaks it, and which call is left wrong

The edit adds a required argument to one handler, so the refund email can say why:

python# handlers.py:37, after the edit def on_refunded(order: Order, reason: str) -> None:

The direct test was updated with it (test_handlers.py:27 passes "damaged in transit"), so the handler tests pass. Every other call to on_refunded that a search can find is correct.

The call that is still wrong is before/orders/signals.py:14:

pythonhandler(instance)

When an order's status is refunded, line 12 resolves on_refunded, and line 14 calls it with one argument. Python raises TypeError for the missing reason, inside the receiver, during Order.save(). The refund request fails at the save call. Django's docs say send() does not catch exceptions raised by receivers and lets them propagate; the same page says "All built-in signals, except those in the async request-response cycle, are dispatched using Signal.send()", and the signals reference says all built-in signals are sent using send(), so post_save goes through send() (https://docs.djangoproject.com/en/stable/topics/signals/ and https://docs.djangoproject.com/en/stable/ref/signals/).

Why no existing test reaches line 14 with a refunded order: test_signals.py saves placed and shipped orders only. The refund test (test_refunding_an_order_sends_refund_notice) is written the way it would be written, and shipped marked @unittest.expectedFailure with the reason in a comment, so the suite shows the gap instead of passing around it.

4. The fix

Replace the string-built name with an explicit match in which each branch is an ordinary call, connect the receiver with its sender, and load signals.py from AppConfig.ready().

diff--- before/orders/signals.py+++ after/orders/signals.py+from typing import Any+ from django.db.models.signals import post_save from django.dispatch import receiver from orders import handlers from orders.models import Order -@receiver(post_save)-def dispatch_order_status(sender, instance, **kwargs):- if not isinstance(instance, Order):- return- handler = getattr(handlers, f"on_{instance.status}", None)- if handler is not None:- handler(instance)+@receiver(post_save, sender=Order)+def dispatch_order_status(+ sender: type[Order], instance: Order, **kwargs: Any+) -> None:+ match instance.status:+ case Order.Status.PLACED:+ handlers.on_placed(instance)+ case Order.Status.PAID:+ handlers.on_paid(instance)+ case Order.Status.SHIPPED:+ handlers.on_shipped(instance)+ case Order.Status.REFUNDED:+ handlers.on_refunded(instance, instance.refund_reason)+ case _:+ pass # draft: nothing to send
diff--- before/orders/apps.py+++ after/orders/apps.py class OrdersConfig(AppConfig): name = "orders"++ def ready(self) -> None:+ # Importing the module connects the @receiver functions in it.+ from orders import signals # noqa: F401
diff--- before/orders/models.py+++ after/orders/models.py---# Connect the receivers defined in orders/signals.py.-from orders import signals # noqa: E402,F401

5. Why the fix works

  • Each handler call is a call a tool can see. handlers.on_refunded(instance, instance.refund_reason) at after/orders/signals.py:22 names the function. A search for callers lists it. Had the edit been made against this file, leaving handlers.on_refunded(instance) in place, a type checker compares the call to def on_refunded(order: Order, reason: str) in the plain handlers.py and reports the missing argument at that line. That holds without Django stubs, because the mismatch is between two ordinary Python functions.
  • A rename can't skip an order silently. The old None default is gone. Renaming on_shipped now leaves handlers.on_shipped(instance) pointing at a name that no longer exists, which a language server rename updates and a type checker flags.
  • **sender=Order says which model the receiver serves.** The signals topic in the Django docs shows @receiver(pre_save, sender=MyModel) and states the handler is called only when an instance of that model is saved. The post_save form works the same way: the signals reference describes post_save as "Like pre_save, but sent at the end of the save() method", with sender as the model class; the topic page prints no post_save example. The isinstance guard is no longer needed.
  • Loading is in one named place. Django's docs say signal receivers are usually defined in a signals submodule and connected in the ready() method of the app configuration, and that with the receiver() decorator you import the signals submodule inside ready(). AppConfig.ready() is called once the app registry is fully populated.
  • The test now covers the refund path. after/orders/tests/test_signals.py saves a refunded order and checks that the reason is in the email.

The ready() docs add one caution that applies here: ready() can run more than once in tests that change installed apps, so registration should be safe to repeat. A @receiver function connected by import is, because it is connected on first import of the module.

6. Rules that apply

  • AR-07: don't build method names as strings.
  • AR-08: dispatch over a closed set with a literal-keyed mapping or an explicit match. The order status is a closed set.
  • AR-31: Django signals get an explicit sender and live in one signals.py per app, imported in AppConfig.ready().
  • AR-25: before changing a signature, list every caller and every dynamic site that might reach it. The getattr site is the one a plain search won't list.

7. References

Django (fetched 2026-10-30 from the stable docs):

  • Signals topic (where to put receivers, connecting in ready(), @receiver(..., sender=...), dispatch_uid, send() vs send_robust()): https://docs.djangoproject.com/en/stable/topics/signals/
  • Applications reference (AppConfig.ready(), when it runs, registering signals): https://docs.djangoproject.com/en/stable/ref/applications/

Changelog

  • 2026-10-30: verify items resolved (docs check)

Last verified

For agentsthis page as Markdown/agent-readable/ch-01.mdllms.txt