Agent Rules / Frameworks
Why does my coding agent miss Django signal receivers?
Rule AR-31
Connect Django signals with an explicit sender, and keep the receivers in one signals.py per app, imported in AppConfig.ready().
A receiver has no caller in the source. Django calls it when a model is saved, so a search for who uses the function finds only its definition. Name the sender in the decorator, put every receiver of an app in one file, and import that file from ready(). Then a reader, a type checker or an agent can answer two questions from the source alone: which model triggers this function, and where does it get connected.
Why it happens
Django's signal system does its job well. It decouples the code that saves a model from the code that reacts, send() runs receivers in a fixed order, and the @receiver decorator keeps the registration next to the function. The cost is that the link between sender and receiver lives in Django's registry, not in a call a tool can follow.
The signals topic in the Django docs says what the registry does with each choice:
- Where receivers live. "Strictly speaking, signal handling and registration code can live anywhere you like, although it's recommended to avoid the application's root module and its
modelsmodule to minimize side-effects of importing code." Receivers belong in asignalssubmodule, connected inready(): "If you're using thereceiver()decorator, import thesignalssubmodule insideready(), this will implicitly connect signal receivers." - **What
senderdoes.** For@receiver(pre_save, sender=MyModel), the docs say the function "will only be called when an instance ofMyModelis saved." Leavesenderout and the receiver is connected to every sender of that signal, so the function has to filter at runtime. - **When
ready()runs.** The applications reference says it is "called as soon as the registry is fully populated", the third and final stage of initialization. It also warns thatready"might be called more than once" in tests that change installed apps, so registration should be idempotent. - Order. "All of the signal's receiver functions are called one at a time, in the order they were registered." Registration order follows import order, so a receiver connected as a side effect of importing
models.pyis placed by whatever importsmodels.pyfirst. - Errors.
send()"does not catch any exceptions raised by receivers; it simply allows errors to propagate." A receiver that breaks after a refactor fails thesave()call that triggered it. CH-01 shows that failure end to end.
The agent's problem follows from this. Asked to change notify_warehouse(order), it searches for callers and finds none in the project. It has no way to know that every Order.save() calls it, or that a receiver without sender also runs for models the agent never opened.
Bad and good
An order service notifies the warehouse after an order is saved.
# bad: orders/models.py
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
class Order(models.Model):
status = models.CharField(max_length=20)
@receiver(post_save) # connected for every model's save
def notify_warehouse(sender, instance, created, **kwargs):
if sender.__name__ != "Order": # runtime filter by name
return
if created:
warehouse.send(instance.pk)Three things hide here. The receiver is connected to every model's post_save. The filter compares a class name as a string. The receiver sits in models.py, which the Django docs say to avoid for registration code. An agent that renames Order finds the string "Order" only by text search, and the receiver quietly stops matching.
# good: orders/signals.py
from typing import Any
from django.db.models.signals import post_save
from django.dispatch import receiver
from orders import warehouse
from orders.models import Order
@receiver(post_save, sender=Order)
def notify_warehouse(
sender: type[Order], instance: Order, created: bool, **kwargs: Any
) -> None:
if created:
warehouse.send(instance.pk)# good: orders/apps.py
from django.apps import AppConfig
class OrdersConfig(AppConfig):
name = "orders"
def ready(self) -> None:
# Importing the module connects the @receiver functions in it.
from orders import signals # noqa: F401models.py no longer mentions signals. sender=Order is a reference to the class, so a rename tool updates it and a search for Order lists the receiver file. All receivers of the app are in one file, so "what reacts to saves in this app" is one file to open.
Trade-offs and when the other way is fine
- The decorator keeps things short.
@receiveris the most readable form Django offers. The rule asks for one more argument and one more file, not a different mechanism. - A receiver with no sender is right for cross-cutting work. Audit logging for every model, or a metrics hook on
request_finished, should not name a sender. Keep those in asignals.pythat says so in a comment. - Signals are the wrong tool when you own both sides. If a view saves an order and sends one email, calling
send_confirmation(order)from the view is a plain call that every tool follows. Use signals when the sender can't know about the receiver, as with third-party apps. - **
ready()adds a file and a few lines per app.** That is the whole cost. - **Importing
signalsinsideready()hides it from a plain top-of-file import scan.** Thenoqa: F401comment and the namesignalsare the convention. A comment inapps.pynaming the receivers' file helps.
Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| ruff | DJ013 non-leading-receiver-decorator (select the DJ family; the rule is not marked preview, and ruff's default rule set lists no DJ rule, so it is off until selected: https://docs.astral.sh/ruff/default-rules/) | a decorator placed above @receiver; the docs say @receiver "does not return a wrapped function", so receivers listed after another decorator "will not be connected" | 2026-10-30 |
| Django system check | signals.E001 (run python manage.py check) | a receiver connected with a string sender app_label.Model where the app isn't installed or has no such model | 2026-10-30 |
| Django | dispatch_uid on connect() | duplicate registration of a bound-method receiver; module-level functions are registered once already | 2026-10-30 |
| Django | weak=False on connect() | a local-function receiver being garbage collected, since receivers are stored as weak references by default | 2026-10-30 |
No ruff or Django check in the pages read flags a @receiver without sender. That one is a review rule, and the AGENTS.md block below is how an agent learns it.
Rule block for AGENTS.md / CLAUDE.md
## Django signals
- Every @receiver names its sender: @receiver(post_save, sender=Order). A receiver with no sender needs a comment saying why it listens to all senders.
- Receivers live in <app>/signals.py. Nothing in models.py or the app's __init__.py connects signals.
- apps.py imports the signals module inside AppConfig.ready().
- @receiver is the first decorator above the function.
- Before changing a receiver's signature or deleting it, search for the signal name and the sender class. Callers of the receiver itself are not in the source.
- Don't call handler functions by names built from data (getattr, f-strings). Use an explicit match (AR-07, AR-08).Edge cases
- Bound-method receivers. Connecting a method of an instance that is created again registers another receiver. Pass
dispatch_uid(the docs: "The receiver will only be bound to the signal once for each uniquedispatch_uidvalue"). - Receivers defined inside a function. The docs: "if your receiver is a local function, it may be garbage collected." Define it at module level, or pass
weak=False. - String senders. The applications reference allows
pre_save.connect(receiver, sender="app_label.MyModel")insideready(). Use it to avoid an import cycle between apps; thesignals.E001check covers a wrong label. - **A plain
"orders"entry inINSTALLED_APPS.** For an app listed by package name, Django uses the oneAppConfigsubclass it finds inapps.py("if Django finds exactly oneAppConfigsubclass in theapps.pysubmodule, it uses that configuration"), and then "runs theready()method of each application configuration". With no subclass, the baseAppConfigis used and itsready()does nothing; with several, the one withdefault = Trueis chosen. https://docs.djangoproject.com/en/stable/ref/applications/ - Async receivers. The docs say receivers may be
async def, and "an asynchronous receiver registered before a synchronous receiver may be executed after the synchronous receiver", so don't rely on registration order across the two. - Tests.
ready()may run more than once during testing. Module-level function receivers connected by import are safe. - Dispatch by status inside a receiver. A
post_savereceiver that routes by a status field belongs to AR-07 and AR-08 as well. See CH-01.
What LLMGRAPH shows
A function connected with the receiver decorator prints 'no calls to it found in this repo'; LLMGRAPH does not mark it as an entry point.
References
Django (fetched 2026-10-30 from the stable docs):
- Signals topic (where to put receivers,
ready(),@receiver(sender=...),dispatch_uid, weak references, call order,send()errors): https://docs.djangoproject.com/en/stable/topics/signals/ - Applications reference (
AppConfig.ready(), initialization stages, string sender labels,ready()more than once): https://docs.djangoproject.com/en/stable/ref/applications/ - System check framework (
signals.E001): https://docs.djangoproject.com/en/stable/ref/checks/
Ruff (fetched 2026-10-30):
- DJ013: https://docs.astral.sh/ruff/rules/django-non-leading-receiver-decorator/ · rule list: https://docs.astral.sh/ruff/rules/
Chimera: CH-01.
Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved
- 2026-10-30: remaining checks closed (claims reduced to documented behaviour)
Related
- AR-07 Why does my coding agent miss calls made through getattr?
- AR-08 How do I write dispatch that type checkers can follow?
- AR-25 What to check before changing a function signature?
- AR-32 Do string model references hide dependencies in Django?
- CH-01 Why does my Django signal break after a refactor?
Chimeras that use this rule
- CH-01 Why does my Django signal break after a refactor?
Last verified