Аудит изменений в Django: как делать правильно
Кто изменил цену товара с 1000 до 1 рубля и когда — типичный вопрос, на который проект без аудита ответить не может. Разбираем рабочие подходы к журналированию изменений моделей в Django.

Заказчик спрашивает, кто обнулил остатки на складе в ночь на субботу. В базе — только текущее состояние строки: остаток 0, поле updated_at с временем изменения, и всё. Кто, что было раньше, из какого места кода пришёл запрос — не восстановить. Аудит изменений решает ровно эту задачу: хранит историю того, что менялось, кем и когда. Разберём три рабочих подхода в Django 4.2/5.x, цену каждого и типичные ошибки, из-за которых аудит либо не пишется, либо врёт.
Что вообще считать аудитом
Смешивают три разные вещи, и от этого путаница в требованиях.
- Журнал изменений модели — версии строки: было имя «Иванов», стало «Петров», изменил пользователь id=5 в 14:03. Это то, что чаще всего называют аудитом.
- Лог действий пользователя — «пользователь открыл отчёт», «экспортировал CSV». Не про данные, а про поведение. Пишется отдельно, обычно в приложение или во внешнюю систему.
- WAL и репликация базы — низкоуровневая история на уровне СУБД. Восстанавливает состояние, но не отвечает на вопрос «какой пользователь приложения это сделал».
Дальше — только про первый пункт: историю изменений моделей средствами Django.
Подход 1. Готовая библиотека django-simple-history
Самый быстрый путь. Библиотека создаёт для каждой отслеживаемой модели теневую таблицу historical* и пишет туда полную копию строки при каждом сохранении и удалении.
from django.db import models
from simple_history.models import HistoricalRecords
class Product(models.Model):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
# добавляет таблицу истории и менеджер .history
history = HistoricalRecords()
После makemigrations и migrate у каждой строки появляется история:
# все версии конкретного товара, от новой к старой
for record in product.history.all():
print(record.history_date, record.history_user_id, record.price)
# что изменилось между двумя соседними версиями
new = product.history.first()
old = new.prev_record
if old:
delta = new.diff_against(old)
for change in delta.changes:
print(change.field, change.old, change.new)
Чтобы в историю попадал пользователь, а не пустое поле, библиотеке нужен доступ к текущему запросу. Для этого в MIDDLEWARE добавляют simple_history.middleware.HistoryRequestMiddleware. Без него history_user будет пустым при сохранении из вьюхи, и вы получите журнал без ответа на главный вопрос — кто.
Цена. Таблица истории растёт быстрее основной: полная копия строки на каждое сохранение. Для таблицы с частыми апдейтами это гигабайты за месяцы. Схема истории должна мигрировать вместе с основной моделью — добавили поле в Product, библиотека сгенерирует миграцию и для HistoricalProduct.
Подход 2. Django admin LogEntry
Если изменения идут только через админку, история уже пишется — в модель django.contrib.admin.models.LogEntry. Она фиксирует, кто, что и когда добавил, изменил или удалил, и в поле change_message хранит список затронутых полей.
from django.contrib.admin.models import LogEntry, CHANGE
from django.contrib.contenttypes.models import ContentType
ct = ContentType.objects.get_for_model(Product)
changes = LogEntry.objects.filter(
content_type=ct,
action_flag=CHANGE,
).order_by("-action_time")
for entry in changes:
print(entry.action_time, entry.user_id, entry.get_change_message())
Цена. LogEntry не хранит старые и новые значения — только факт «поле price изменилось». Восстановить, каким price было до, по нему нельзя. И он молчит про любые изменения мимо админки: массовый Product.objects.update(), вызовы из API, скрипты, management-команды. Как единственный источник аудита не годится.
Подход 3. Свой журнал на сигналах или переопределении save
Когда нужен контроль над тем, что и как пишется — например, хранить только изменения цены, а не всю строку — пишут собственную таблицу. Ключевая ошибка новичков здесь: пытаться поймать «старое значение» в сигнале post_save, где его уже нет. Старое значение живёт до сохранения, поэтому его читают в pre_save либо переопределяют save().
from django.db import models
class AuditLog(models.Model):
model_name = models.CharField(max_length=100)
object_id = models.CharField(max_length=64)
field = models.CharField(max_length=100)
old_value = models.TextField(null=True)
new_value = models.TextField(null=True)
user_id = models.IntegerField(null=True)
created_at = models.DateTimeField(auto_now_add=True)
class Product(models.Model):
name = models.CharField(max_length=200)
price = models.DecimalField(max_digits=10, decimal_places=2)
def save(self, *args, **kwargs):
old_price = None
if self.pk:
# читаем прежнее значение из базы до записи
old_price = Product.objects.filter(pk=self.pk).values_list(
"price", flat=True
).first()
super().save(*args, **kwargs)
if old_price is not None and old_price != self.price:
AuditLog.objects.create(
model_name="Product",
object_id=str(self.pk),
field="price",
old_value=str(old_price),
new_value=str(self.price),
)
Здесь два подводных камня, которые ломают такой аудит на проде.
- Лишний SELECT на каждое сохранение. Чтение старого значения — это отдельный запрос к базе. На горячем пути с тысячами апдейтов в минуту это заметная нагрузка.
QuerySet.update()иbulk_*не вызываютsave()и не шлют сигналыpre_save/post_save. Массовое обновлениеProduct.objects.filter(...).update(price=1)пройдёт мимо любого аудита на уровне модели. Это же касаетсяbulk_createиbulk_update.
Аудит, который молчит про update(), опаснее отсутствия аудита: он создаёт ложное чувство, что история полная. А обнулили остатки как раз массовым апдейтом.
Куда девать пользователя вне запроса
Модель ничего не знает о текущем пользователе — она может сохраняться из Celery-задачи, где запроса нет вовсе. Стандартный приём — прокидывать пользователя через threading.local или contextvars, заполняя его в middleware. Именно так и работает HistoryRequestMiddleware из первого подхода. Для фоновых задач пользователя придётся передавать явным параметром — из контекста запроса его там взять неоткуда.
Что выбирать
| Подход | Хранит старое значение | Ловит update() и bulk | Когда брать |
|---|---|---|---|
| django-simple-history | Да, полную копию строки | Нет | Нужна полная версионность моделей, готов платить объёмом |
| admin LogEntry | Нет | Нет | Все правки только через админку, хватает факта изменения |
| Свой журнал | Да, выборочно по полям | Нет (нужен уровень БД) | Нужен контроль над объёмом и составом журнала |
| Триггеры БД | Да | Да | Требование не упустить ни одно изменение, включая обход ORM |
Строка про триггеры здесь не случайна. Все три подхода на уровне Django объединяет общая слепая зона: они не видят изменений в обход ORM — прямой SQL, миграции данных, чужой сервис, пишущий в ту же базу. Если требование звучит как «зафиксировать любое изменение таблицы», единственный надёжный уровень — триггеры AFTER UPDATE/DELETE в самой СУБД, пишущие в аудит-таблицу. Синтаксис триггеров в PostgreSQL 16 смотрите в разделе CREATE TRIGGER официальной документации. Настройка триггеров — отдельная тема, здесь только фиксируем границу применимости ORM-подходов.
Частые ошибки
- Читать старое значение в
post_save. Там в базе уже новое. Старое берут вpre_saveили переопределённомsave()до вызоваsuper().save(). - Забыть про транзакции. Если запись в аудит идёт отдельным
create(), а основная транзакция откатилась, в журнале останется изменение, которого в данных нет. Аудит должен писаться в той же транзакции, что и само изменение. - Хранить пароли и токены. Полная копия строки в simple-history утащит в историю и хеши, и чувствительные поля. Такие поля исключают через параметр
excluded_fields. - Не думать про рост таблицы. Аудит-таблица без плана ротации и партиционирования однажды займёт больше места, чем боевые данные.
Что делать дальше
Определите требование до выбора инструмента: нужен читаемый журнал правок из админки — хватит LogEntry; нужна версионность моделей — берите django-simple-history; нужна гарантия «ни одно изменение не потеряно» — уходите на триггеры БД. Затем настройте ротацию: партиционирование аудит-таблицы по дате или перенос старых записей в холодное хранилище. И проверьте слепую зону руками — сделайте тестовый objects.update() и убедитесь, попал ли он в журнал. Если нет, вы знаете, где дыра.
Тренажёр к экзамену: Вопросы, проверка ответов, план подготовки
Спросить за 10 ₽Источники: PostgreSQL 16 — CREATE TRIGGER, Django — LogEntry и admin history
Частые вопросы
Почему история пишется без пользователя — поле history_user пустое?
У меня Django 4.2, а не 5.x — подходы отличаются?
Почему массовое обновление не попадает в аудит?
Насколько сильно растёт база от аудита?
Что делать, если запись в аудит прошла, а основная транзакция откатилась?
Можно ли восстановить прежнее состояние строки по admin LogEntry?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.