Аудит изменений моделей в Django: типичные ошибки
Вы включили django-simple-history, а таблицы истории разрослись быстрее основных, часть изменений в лог не попала, а bulk_update прошёл мимо аудита. Разбираем, где ломается аудит моделей и как чинить.

Аудит изменений в Django — это ответ на вопрос «кто, когда и что поменял в этой записи». Задача звучит просто: логировать изменения полей модели. На практике самописный аудит через save() молча теряет часть операций, популярный django-simple-history раздувает базу, а сигналы срабатывают не там, где вы ждёте. Разберём четыре места, где аудит ломается тише всего, и что с этим делать. Ориентируюсь на Django 4.2 LTS и 5.x — поведение сигналов и QuerySet-методов в этих версиях одинаковое.
Что вообще считается «изменением»
Первая ошибка — до кода. Прежде чем логировать, договоритесь, что именно фиксируете. Аудит на уровне ORM ловит не все пути записи в таблицу:
instance.save()— ловится сигналами и переопределённымsave();QuerySet.update()— идёт напрямую в SQL,save()и сигналыpre_save/post_saveне вызываются;QuerySet.bulk_update()иbulk_create()— по умолчанию сигналы не шлют;QuerySet.delete()— шлётpost_deleteпо каждому объекту, но каскадные удаления через БД могут пройти мимо;- прямой SQL через
connection.cursor()или миграции сRunSQL— ORM о них не знает вообще.
Если аудит нужен «для галочки» перед разработчиками — хватит сигналов. Если он нужен для расследования инцидентов с данными или для комплаенса — учитывайте, что часть путей записи ORM не перехватывает в принципе.
Аудит, который ловит только save(), — это не журнал изменений, а журнал вежливых изменений. Массовые операции проходят мимо, и именно они чаще всего портят данные.
Ошибка 1: логика аудита в save() вместо сигналов
Соблазн большой: переопределить save() модели и там же писать в лог. Проблема в том, что save() вызывается не всегда, а вот сигналы дают более предсказуемую точку. Но и у сигналов есть ловушка — в post_save вы уже не видите прежних значений полей, объект в памяти обновлён.
Чтобы поймать что было и что стало, старое состояние читают в pre_save, отдельным запросом по pk:
# Django 4.2 / 5.x
from django.db.models.signals import pre_save
from django.dispatch import receiver
from myapp.models import Order, OrderChange
@receiver(pre_save, sender=Order)
def capture_order_changes(sender, instance, **kwargs):
# Новый объект — сравнивать не с чем
if instance._state.adding:
return
try:
old = sender.objects.get(pk=instance.pk)
except sender.DoesNotExist:
return
tracked_fields = ["status", "total", "manager_id"]
diffs = {}
for field in tracked_fields:
old_value = getattr(old, field)
new_value = getattr(instance, field)
if old_value != new_value:
diffs[field] = {"from": old_value, "to": new_value}
# Прячем diff на инстансе, чтобы записать после успешного save
instance._pending_audit = diffs
Обратите внимание: в pre_save мы только собираем разницу, но не пишем в базу. Запись делаем в post_save — иначе при откате транзакции в лог попадёт изменение, которого фактически не было. Проверка результата: после обновления заказа в OrderChange должна появиться строка с непустым JSON в поле diff.
Лишний запрос на каждое сохранение
Чтение old = sender.objects.get(pk=...) — это дополнительный SELECT при каждом save(). На горячих моделях, которые сохраняются в цикле, это удваивает число запросов. Если модель меняется часто и массово, аудит через отдельный SELECT в сигнале станет узким местом — тогда смотрите в сторону триггеров БД.
Ошибка 2: массовые операции проходят мимо
Классика: аудит настроен, тесты зелёные, а через месяц выясняется, что операция «пересчитать статусы всех заказов за день» через Order.objects.filter(...).update(status="done") не оставила ни одной записи в журнале. Так и задумано в Django: update() транслируется в один UPDATE и не поднимает сигналы.
Варианта два. Либо запретить массовые операции на аудируемых моделях соглашением команды и ловить это на ревью, либо не рассчитывать на ORM-сигналы вовсе и вешать аудит на уровень базы данных — триггерами.
| Способ аудита | Ловит update()/bulk | Прямой SQL | Нагрузка | Сложность |
|---|---|---|---|---|
| Сигналы pre_save/post_save | Нет | Нет | +SELECT на save | Низкая |
| django-simple-history | Нет (по умолчанию) | Нет | +INSERT на save | Низкая |
| Триггеры БД (pgaudit / django-pghistory) | Да | Да | На стороне БД | Средняя |
Если требование — «ни одно изменение не должно потеряться», единственный честный ответ — триггеры на уровне PostgreSQL. Приложение может писать в базу разными путями, а триггер сработает на любой UPDATE/DELETE строки. Библиотека django-pghistory генерирует такие триггеры из описания модели; проверяйте актуальную поддержку вашей версии Django и Postgres в её документации, синтаксис объявления менялся между мажорными релизами.
Ошибка 3: логируем всё подряд и раздуваем базу
Если фиксировать полный снимок объекта при каждом сохранении (так по умолчанию работает django-simple-history), таблица истории растёт быстрее основной. У модели с 30 полями и частыми обновлениями история за год легко превышает исходные данные в разы, а JOIN и отчёты по ней замедляются.
Что помогает:
- Логируйте только те поля, которые реально расследуют. Список
tracked_fieldsиз примера выше — это не мелочь, а способ не хранить мусор. - Храните diff (что изменилось), а не полный снимок, если полная реконструкция состояния на любой момент вам не нужна.
- Настройте ретеншн: удаляйте записи старше N месяцев по расписанию. У
django-simple-historyдля очистки есть management-команда; точное имя и флаги сверьте в её документации, чтобы не запустить не то. - Выносите историю в отдельную таблицу с собственными индексами по
object_idиcreated_at— именно по ним идут запросы «покажи историю этого заказа».
Индекс, о котором забывают
Журнал изменений почти всегда читают двумя способами: «вся история одного объекта» и «все изменения за период». Без составного индекса по (object_id, created_at) первый запрос при большой таблице делает Seq Scan. Добавьте индекс сразу — на пустой таблице это бесплатно, на заполненной уже больно.
Ошибка 4: нет автора изменения
Сигналы модели ничего не знают о HTTP-запросе и текущем пользователе — они работают на уровне ORM. Поэтому в pre_save вы не можете просто взять request.user. Если писать в лог только «что изменилось» без «кто изменил», половина ценности аудита теряется.
Автора пробрасывают через middleware, которое кладёт текущего пользователя в thread-local хранилище на время запроса, а сигнал читает его оттуда. Это рабочий, но хрупкий приём: он ломается в фоновых задачах Celery, в management-командах и в тестах, где HTTP-запроса нет. Для таких случаев предусмотрите значение по умолчанию — например, system — вместо падения с ошибкой.
Проверка результата: изменение через админку должно записать реального пользователя, а изменение из Celery-задачи — метку system, а не пустое поле и не ошибку.
Если аудит уже течёт: с чего начать проверку
- Найдите в коде все вызовы
.update(,.bulk_update(,.delete()иRunSQLна аудируемых моделях — это дыры в покрытии. - Сравните число записей в основной таблице и в таблице истории. Если история кратно больше, вы логируете лишнее.
- Посмотрите план запроса «история одного объекта» через
EXPLAIN— если тамSeq Scan, не хватает индекса. - Проверьте, что запись в лог происходит в той же транзакции, что и само изменение, иначе при откате останутся фантомные записи.
Что делать дальше
Определитесь с требованием к полноте: «журнал для разработчиков» живёт на сигналах, «журнал для расследований и комплаенса» — на триггерах БД. Смешивать бессмысленно. Дальше — настройте ретеншн, добавьте составной индекс по (object_id, created_at) и напишите тест, который делает update() и проверяет, попало ли изменение в аудит: этот тест либо подтвердит, что вы закрыли дыру с массовыми операциями, либо честно покажет, что нет. Инвалидация и архивация старой истории — отдельная тема, здесь мы разбирали только полноту и корректность записи.
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: Django documentation — Signals (pre_save/post_save), Django documentation — QuerySet API: update, bulk_update
Частые вопросы
Почему QuerySet.update() не попадает в аудит на сигналах?
А если у меня другая версия Django — приёмы сработают?
Что делать, если в аудит попадают изменения, которых не было?
Аудит через отдельный SELECT в сигнале сильно бьёт по производительности?
Как записать автора изменения, если сигнал не видит request.user?
Стоит ли хранить полный снимок объекта или только diff?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.