Сигналы Django: когда использовать, а когда нет
Сигналы удобны для развязки кода, но именно они превращают простой save() в цепочку скрытых побочных эффектов. Разбираем, где сигнал оправдан, а где лучше явный вызов.

Вы добавили post_save на модель Order, чтобы отправлять письмо после создания заказа. Через полгода никто в команде не может объяснить, почему при импорте 10 000 заказов из CSV уходит 10 000 писем, тесты падают на ровном месте, а bulk_create тихо ничего не отправляет. Это классическая история про сигналы Django: инструмент рабочий, но применённый не там, где нужно. Разбираем на Django 5.x, когда сигнал — правильное решение, а когда он маскирует связанность, которую лучше сделать явной.
Что вообще делает сигнал
Сигнал — это механизм «отправитель уведомляет подписчиков о событии, не зная, кто они». Django из коробки шлёт pre_save, post_save, pre_delete, post_delete, m2m_changed, request_started и другие. Обработчик подключается через @receiver или Signal.connect() и вызывается синхронно в том же потоке и той же транзакции, что и отправитель.
Ключевое слово — синхронно. Сигнал не делает код асинхронным и не выносит работу в фон. Он просто прячет вызов функции за событием. Если обработчик post_save делает HTTP-запрос на 400 мс, ваш save() теперь занимает 400 мс, и по коду вызова этого не видно.
Сигнал не убирает связанность между кодом — он делает её невидимой. Иногда это то, что нужно. Чаще — нет.
Три места, где сигнал оправдан
Есть ситуации, где развязка через событие действительно снижает связанность, а не прячет её.
- Реакция на чужую модель. Вам нужно создавать
Profileпри регистрацииUser, но модельUser— изdjango.contrib.authили стороннего пакета, и вы не можете переопределить еёsave(). Сигналpost_saveнаUser— легитимный способ вклиниться. - Кросс-приложенческая реакция без жёсткой зависимости. Приложение
billingхочет знать о событиях вorders, но вы не хотите, чтобыordersимпортировалbilling. Сигнал разрывает импорт в одну сторону. - Точки расширения для плагинов. Вы пишете переиспользуемое приложение и хотите дать пользователям хук, не заставляя их править ваш код. Собственный
Signal— стандартный для этого паттерн.
Как выглядит обоснованный случай
# signals.py в приложении accounts (Django 5.x)
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from .models import Profile
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_profile(sender, instance, created, **kwargs):
# Профиль создаём только при первом сохранении пользователя
if created:
Profile.objects.create(user=instance)
Здесь сигнал уместен: User — не ваша модель, а зависимость направлена в одну сторону. Обработчик регистрируется в AppConfig.ready(), а не в models.py, иначе он не подключится:
# apps.py
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = "accounts"
def ready(self):
# Импорт ради регистрации @receiver, побочный эффект — это норма здесь
from . import signals # noqa: F401
Четыре ловушки, из-за которых сигналы ругают
1. bulk_create и bulk_update молчат
Методы QuerySet.bulk_create() и bulk_update() по умолчанию не отправляют pre_save/post_save. То же с update() — он не шлёт сигналов вообще, потому что не загружает объекты в память. Код, который «работает при обычном сохранении», незаметно перестаёт работать при массовых операциях. Если ваша бизнес-логика висит на сигнале, массовый импорт её обойдёт.
2. Сигнал живёт внутри транзакции
post_save вызывается до коммита транзакции. Если внутри обработчика вы ставите задачу в очередь (Celery, RQ), воркер может подхватить её раньше, чем данные окажутся в базе, и получить «объект не найден». Отправка письма из post_save внутри transaction.atomic() уйдёт даже при последующем откате транзакции.
Решение — 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 # Celery task
@receiver(post_save, sender=Order)
def notify_on_order(sender, instance, created, **kwargs):
if not created:
return
# Задача уйдёт только после успешного коммита транзакции
transaction.on_commit(lambda: send_order_email.delay(instance.pk))
3. Порядок и невидимые побочные эффекты
Подписчиков может быть несколько, порядок их вызова не гарантирован строго и зависит от порядка подключения. При отладке падения теста вы читаете view, видите один save() и не понимаете, откуда взялись три записи в других таблицах и одно письмо. Grep по post_save по всему проекту — единственный способ узнать полный список последствий одного сохранения.
4. Тесты становятся хрупкими
Любая фабрика, создающая объект в тесте, дёргает все сигналы. Тест про валидацию формы вдруг лезет в SMTP или в платёжный API, потому что где-то на post_save висит обработчик. Приходится отключать сигналы через @mock.patch или signal.disconnect(), и это загрязняет каждый тест.
Когда сигнал — плохая замена явному вызову
Если и отправитель, и обработчик находятся в одном приложении и вы контролируете обе стороны — сигнал почти всегда лишний. Логику, которая должна выполняться при сохранении вашей же модели, чаще правильнее держать в переопределённом save() или, ещё лучше, в сервисном слое.
Было — на сигнале
# Побочный эффект спрятан, save() выглядит невинно
@receiver(post_save, sender=Order)
def recalc_total(sender, instance, **kwargs):
instance.total = sum(i.price for i in instance.items.all())
# Осторожно: это вызовет ещё один save() и рекурсию по сигналу
Стало — сервисная функция
# services.py — вызов явный, видно в коде, легко тестировать
from django.db import transaction
from .models import Order, OrderItem
@transaction.atomic
def create_order(user, items_data):
order = Order.objects.create(user=user)
OrderItem.objects.bulk_create([
OrderItem(order=order, price=d["price"], name=d["name"])
for d in items_data
])
order.total = sum(d["price"] for d in items_data)
order.save(update_fields=["total"])
return order
Сервисная функция явно показывает всё, что происходит при создании заказа, не ломается на bulk_create, легко покрывается тестом и не имеет проблемы рекурсивного save().
Как выбрать: сигнал или явный вызов
| Ситуация | Сигнал | Явный вызов / сервис |
|---|---|---|
| Реакция на модель из чужого пакета | Да | Нельзя |
| Обе модели в вашем приложении | Нет | Да |
| Хук для плагинов вашего пакета | Да | Нет |
| Логика должна работать при bulk-операциях | Нет, обойдёт | Да |
| Кросс-приложенческая реакция без импорта | Возможно | Да, если импорт допустим |
| Постановка фоновой задачи | Только с on_commit | Да, из сервиса |
Практический критерий
Задайте один вопрос: контролирую ли я код отправителя? Если да и обе стороны в одном приложении — пишите явный вызов, обычно в сервисном слое. Сигнал оставьте для случаев, где переопределить save() вы физически не можете (чужая модель) или где развязка — заявленная цель (точки расширения плагина).
Что делать дальше
Пройдитесь grep-ом по проекту: grep -rn "post_save\|pre_save\|post_delete" . и для каждого обработчика спросите, не в одном ли приложении лежат отправитель и подписчик. Кандидаты на переезд в сервисный слой — те, что в одном. Отдельно проверьте, что все обработчики, ставящие фоновые задачи, обёрнуты в transaction.on_commit(). Инвалидация кеша через сигналы — отдельная тема, здесь мы её не разбираем. Полный список встроенных сигналов и их аргументов держите под рукой в документации Django.
Аудитор договоров: Word/PDF договоры: скрытые штрафы и пени, протокол разногласий.
Спросить за 20 ₽Источники: Django documentation — Signals, Django documentation — Database transactions: on_commit
Частые вопросы
У меня Django 3.2, а не 5.x — приведённый код работает?
Почему мой обработчик @receiver вообще не вызывается?
Почему при bulk_create сигналы не срабатывают и как это обойти?
Можно ли сделать обработчик сигнала асинхронным, чтобы не тормозить save()?
Сигналы замедляют работу проекта?
Как не сломать тесты из-за сигналов, лезущих во внешние сервисы?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.