Сигналы в Django: как делать правильно и не ловить баги
Сигналы удобны, пока их два. На пятом вы уже не понимаете, почему при сохранении заказа улетело письмо и обнулился счётчик. Разбираем, где сигналы уместны, а где ломают проект.

Классическая ловушка: пишете post_save на модель Order, чтобы при создании заказа отправить письмо. Через полгода при массовом импорте 10 000 заказов через bulk_create писем не приходит вовсе, а при обычном сохранении на каждый заказ прилетает по три письма, потому что три разработчика повесили по обработчику в разных приложениях. Это не баг Django — это симптом того, что сигналы используют не там, где нужно.
Материал про Django 5.x (актуально и для 4.2 LTS). Разбираем, когда сигнал — правильный инструмент, когда он маскирует проблему архитектуры, и как писать обработчики, которые не разваливаются под нагрузкой и не дублируются. Инвалидация кеша через сигналы и связка с Celery — смежные темы, здесь только основы дисциплины.
Что сигнал делает и чего от него ждать не стоит
Сигнал в Django — синхронный вызов. Когда вы отправляете post_save, Django по очереди вызывает все подключённые обработчики в том же потоке и в той же транзакции, что и save(). Это первое, что ломает интуицию новичка: сигнал не «фоновая задача» и не «событие в очереди». Если обработчик делает HTTP-запрос к платёжному шлюзу, ваш save() висит ровно столько, сколько отвечает шлюз.
Второе следствие синхронности: исключение в обработчике всплывает наверх и роняет вызов save(). Django не глотает ошибки receiver'ов молча.
Сигнал хорош там, где отправитель и получатель не должны знать друг о друге. Как только вы пишете обработчик прямо под ту модель, которую сохраняете в соседней строке — вам нужен не сигнал, а обычный метод или сервисная функция.
Где сигналы реально уместны
Правильный сценарий — реакция на события в чужом коде, который вы не можете или не хотите менять.
- Реакция на
Userизdjango.contrib.auth. Создать профиль при регистрации пользователя черезpost_saveмоделиUser— здесь редактировать чужую модель нельзя, сигнал оправдан. - Приложение, которое подключают другие. Библиотека шлёт свой сигнал
payment_completed, а хост-проект решает, что на него навесить. Отправитель ничего не знает о подписчиках. - Кросс-приложенческие эффекты. Приложение
auditслушаетpost_deleteмоделей из других приложений, не создавая на них жёстких зависимостей.
Если событие и реакция живут в одном приложении и вы контролируете обе стороны — почти всегда чище вызвать функцию явно. Явный вызов виден в трассировке стека, ищется по имени и не срабатывает неожиданно.
Четыре ошибки, которые всплывают в проде
1. Двойная регистрация обработчика
Если модуль с @receiver импортируется дважды разными путями, обработчик регистрируется дважды и срабатывает дважды. Django даёт защиту — параметр dispatch_uid:
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Order
# dispatch_uid гарантирует, что при повторном импорте
# обработчик не подключится второй раз
@receiver(post_save, sender=Order, dispatch_uid="order_created_notify")
def notify_on_order(sender, instance, created, **kwargs):
if not created:
return
# реакция только на создание, не на каждое сохранение
send_order_email(instance.id)2. Обработчик срабатывает при каждом save, а не только при создании
post_save шлётся и при создании, и при обновлении. Забыли проверить created — и письмо о новом заказе уходит при каждой смене статуса. Первая строка любого обработчика post_save, завязанного на создание — проверка флага created.
3. bulk_create и bulk_update не шлют сигналов
Это задокументированное поведение, не баг. Методы bulk_create(), bulk_update(), update() и delete() на QuerySet обходят save()/delete() экземпляра и не отправляют post_save/post_delete. Если вся ваша логика создания заказа висит на сигнале, массовый импорт её молча пропустит.
4. Обращение к незакоммиченным данным
post_save срабатывает внутри транзакции, до её коммита. Если обработчик ставит задачу в Celery, воркер может подхватить её раньше, чем транзакция закоммитится, и не найдёт объект в базе. Решение — откладывать побочные эффекты до коммита через transaction.on_commit():
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Order
from .tasks import send_order_email_task
@receiver(post_save, sender=Order, dispatch_uid="order_email_after_commit")
def schedule_email(sender, instance, created, **kwargs):
if not created:
return
# задача уйдёт в очередь только после успешного коммита транзакции
transaction.on_commit(
lambda: send_order_email_task.delay(instance.id)
)Куда класть код обработчиков
Частая ошибка новичка — определить @receiver прямо в models.py. Работает, но смешивает модель и реакцию на неё. Канонический способ — отдельный файл signals.py (или receivers.py) и подключение его в методе ready() конфигурации приложения:
# apps.py
from django.apps import AppConfig
class OrdersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "orders"
def ready(self):
# импорт внутри ready() регистрирует обработчики
# ровно один раз при старте приложения
from . import signals # noqa: F401Импорт именно внутри ready(), а не на верхнем уровне apps.py — иначе ловите циклический импорт моделей.
Сигнал или альтернатива: как выбирать
| Задача | Правильный инструмент | Почему не сигнал |
|---|---|---|
| Логика при создании своей же модели | Переопределить save() или сервисная функция | Явный вызов виден в стеке, работает и при bulk через вашу обёртку |
| Реакция на чужую модель (User) | Сигнал post_save | Модель редактировать нельзя |
| Отправка письма, запрос к API | Celery через on_commit() | Синхронный сигнал держит транзакцию открытой |
| Событие в переиспользуемом приложении | Свой кастомный сигнал | Слабая связанность — весь смысл |
| Валидация перед сохранением | Метод clean() / валидаторы | pre_save не участвует в валидации форм |
Свой сигнал, когда он оправдан
Если вы пишете приложение, которое подключают другие, объявите собственный сигнал явно:
# signals.py
import django.dispatch
# providing_args убрали в Django 3.1 — теперь просто Signal()
order_paid = django.dispatch.Signal()
# где-то в бизнес-логике после подтверждения оплаты
order_paid.send(sender=Order, order=order, amount=order.total)Хост-проект подключается к order_paid, не трогая ваш код. Обратите внимание: аргумент providing_args у Signal() удалён начиная с Django 3.1 — если видите его в старых примерах, не копируйте.
Что делать дальше
Пройдитесь по проекту с одним вопросом к каждому @receiver: контролирую ли я обе стороны — и модель, и реакцию. Если да — кандидат на переезд в сервисную функцию. Дальше проверьте, что все обработчики с побочными эффектами (письма, задачи, внешние запросы) обёрнуты в transaction.on_commit(), а не бьют по сети внутри транзакции. И убедитесь, что нигде создание объекта не идёт через bulk_create в обход сигнала, на котором висит критичная логика. Для отладки того, кто и сколько раз подключён к сигналу, смотрите атрибут receivers у объекта сигнала — там список живых обработчиков.
Мотивационное письмо и эссе: Поступление, стипендии, гранты, обмен
Спросить за 25 ₽Источники: Django documentation — Signals, Django documentation — transaction.on_commit()
Частые вопросы
У меня Django 4.2, а не 5.x — примеры подойдут?
Обработчик всё равно срабатывает дважды, хотя я поставил dispatch_uid — почему?
Почему после bulk_create логика из сигнала не отработала?
Что лучше — сигнал pre_save/post_save или переопределить метод save()?
Задача в Celery не находит объект в базе, хотя сигнал сработал — в чём дело?
Можно ли отключить обработчик временно, например в тестах или при миграции данных?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.