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

Менеджер клянётся, что не менял цену в заказе, а в базе она другая. Без истории изменений вы не докажете ни ему, ни себе, кто прав. Задача аудита моделей звучит просто: сохранять, кто, когда и что поменял в записи. Но наивная реализация через post_save-сигнал ломается на bulk-операциях, раздувает базу и молчит про удаления. Разберём три рабочих подхода на Django 4.2+ и 5.x, у каждого — своя цена.
Что именно приходится логировать
Прежде чем выбирать инструмент, определитесь, что для вас «аудит». В реальных проектах встречаются три разных требования, и они закрывают разные инструменты:
- Кто и когда изменил запись — нужен пользователь и timestamp. Требует доступа к
request.user, а его в модели нет. - Полная версионность — снимок записи на каждый момент, откат к любой версии, дифф между версиями.
- Гарантия, что не пропустим ни одну запись — включая массовые обновления и удаления через
QuerySet.update()иbulk_create(), которые не шлют сигналы.
Наивный подход через сигнал post_save закрывает только первое требование и только частично. QuerySet.update() сигналы не отправляет вообще — это документированное поведение Django, а не баг. Значит, любое обновление в обход save() пройдёт мимо вашего лога незаметно.
Подход 1: django-simple-history
Самый популярный вариант для случая «нужна версионность на уровне ORM». Пакет заводит для каждой отслеживаемой модели теневую таблицу historical*, куда пишет копию строки при каждом сохранении.
# pip install django-simple-history
# версия 3.x, Django 4.2 / 5.x
from django.db import models
from simple_history.models import HistoricalRecords
class Order(models.Model):
number = models.CharField(max_length=32)
price = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=16)
# добавляет модель OrderHistory и связанную таблицу
history = HistoricalRecords()
После makemigrations и migrate у каждого объекта появляется order.history.all() — очередь версий от свежей к старой. Дифф между двумя версиями считается так:
records = order.history.all()
new, old = records[0], records[1]
delta = new.diff_against(old)
for change in delta.changes:
print(change.field, change.old, change.new)
Чтобы в историю попадал пользователь, нужен middleware simple_history.middleware.HistoryRequestMiddleware — он кладёт request в thread-local, откуда пакет достаёт request.user. Без middleware в консольных командах и Celery-задачах поле history_user останется пустым: там нет запроса. Для таких мест пользователя передают вручную через контекстный менеджер пакета.
Чем платите. Таблица истории растёт линейно с числом сохранений. На модели, которую трогают по десять раз в минуту, за год это миллионы строк. По умолчанию пакет не обрабатывает QuerySet.update() — обновление в обход save() в историю не попадёт.
Подход 2: pghistory — триггеры на стороне PostgreSQL
Если вы на PostgreSQL и боитесь пропустить массовые операции, логика на уровне БД надёжнее сигналов. Пакет django-pghistory ставит триггеры прямо на таблицу, поэтому ловит и update(), и удаления, и правки из psql в обход Django.
# pip install django-pghistory
# версия 3.x, только PostgreSQL
import pghistory
from django.db import models
@pghistory.track()
class Order(models.Model):
number = models.CharField(max_length=32)
price = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=16)
Триггер срабатывает в самой базе, поэтому Order.objects.filter(status='new').update(status='paid') тоже окажется в логе — в отличие от сигналов. Пользователя пакет подтягивает через свой middleware, который пишет контекст в переменную сессии PostgreSQL на время запроса.
Сигнал ловит вызов метода. Триггер ловит изменение строки. Если для вас аудит — это гарантия, а не удобство, разница между ними решающая.
Чем платите. Привязка к PostgreSQL: на SQLite и MySQL не заработает. Триггеры добавляют накладной расход на каждую запись — на write-heavy таблицах это заметно под нагрузкой. И отлаживать триггер сложнее, чем питоновский код: он живёт в схеме БД, а не в репозитории.
Подход 3: ручной аудит-лог через одну таблицу
Когда версионность не нужна, а нужен просто журнал «пользователь X поменял поле Y с A на B», теневые таблицы для каждой модели — избыточно. Одна общая таблица через GenericForeignKey компактнее.
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
class AuditLog(models.Model):
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
target = GenericForeignKey('content_type', 'object_id')
user = models.ForeignKey('auth.User', null=True, on_delete=models.SET_NULL)
changes = models.JSONField() # {'price': ['100.00', '120.00']}
created_at = models.DateTimeField(auto_now_add=True)
Чтобы посчитать дифф до сохранения, сравнивают текущее состояние с тем, что в базе. Классический приём — запомнить исходные значения при загрузке объекта:
class Order(models.Model):
price = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=16)
@classmethod
def from_db(cls, db, field_names, values):
instance = super().from_db(db, field_names, values)
# снимок значений на момент загрузки из БД
instance._loaded_values = dict(zip(field_names, values))
return instance
def get_dirty_fields(self):
if not hasattr(self, '_loaded_values'):
return {}
dirty = {}
for field in self._meta.fields:
name = field.attname
old = self._loaded_values.get(name)
new = getattr(self, name)
if old != new:
dirty[name] = [str(old), str(new)]
return dirty
Дальше get_dirty_fields() вызывается в переопределённом save() и пишет запись в AuditLog. Пользователя сюда передают явным аргументом — не полагайтесь на thread-local, если хотите предсказуемости в фоновых задачах.
Чем платите. Пишете и поддерживаете код сами. from_db-снимок не поможет для только что созданных объектов (там нечего сравнивать) и не ловит update(). Зато вы контролируете формат и объём лога полностью.
Когда что выбирать
| Критерий | simple-history | pghistory | ручной лог |
|---|---|---|---|
| Нужна версионность и откат | да | да | нет |
| Ловит QuerySet.update() | нет | да | нет |
| Работает не только на PostgreSQL | да | нет | да |
| Рост объёма БД | высокий | высокий | управляемый |
| Сложность внедрения | низкая | средняя | своя реализация |
Если задача — «покажи историю заказа в админке и дай откатить», берите simple-history: минимум кода, готовый дифф. Если задача — «мы обязаны логировать всё, включая правки из скриптов и update()», и вы на PostgreSQL, берите pghistory. Если нужен лёгкий журнал по нескольким чувствительным полям без версий — своя таблица через GenericForeignKey дешевле по объёму и понятнее в поддержке.
Что ломается на практике
- Пустой пользователь в фоне. Любой подход через thread-local теряет
request.userв Celery, management-командах и миграциях данных. Там передавайте пользователя явно. - bulk_create и update молчат. Сигналы
pre_save/post_saveпри них не шлются — это поведение Django, не пакета. Только триггеры БД надёжно их перехватывают. - История на изменение схемы. При добавлении поля старые записи истории в simple-history не имеют этого столбца в старых версиях — дифф между версиями до и после миграции надо читать аккуратно.
- Раздувание таблиц. Без ретеншена таблицы истории растут неограниченно. Заведите периодическую задачу, которая чистит записи старше нужного срока.
Что настроить дальше
После выбора подхода займитесь двумя вещами, о которых забывают на старте. Первое — ретеншен: решите, сколько храните историю, и повесьте регламентную чистку старых записей. Второе — индексы: запросы к таблице истории почти всегда идут по object_id и дате, без индекса они деградируют по мере роста таблицы. Инвалидация и архивация старой истории — отдельная тема, здесь мы разобрали только сбор изменений. Актуальный набор опций каждого пакета сверяйте с его документацией: API мелко меняется между минорными версиями.
Проверка грамотности: Орфография, пунктуация, стиль — с объяснением каждой правки
Спросить за 10 ₽Источники: django-simple-history documentation, django-pghistory documentation
Частые вопросы
Почему изменения через QuerySet.update() не попадают в историю?
У меня другая версия Django — примеры сработают?
Что делать, если history_user всегда пустой?
Работает ли pghistory на MySQL или SQLite?
Как не дать таблицам истории раздуться до неуправляемых размеров?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.