Soft delete в Django: реализация и скрытые грабли
Мягкое удаление кажется безобидной строкой is_deleted=True, но ломает уникальные индексы, каскады и запросы через ORM. Разбираем, как реализовать soft delete в Django без сюрпризов в проде.

Вы добавили в модель поле is_deleted и переопределили delete(), чтобы вместо реального удаления ставить флаг. Через месяц выясняется: сохранить второго пользователя с тем же email нельзя, потому что уникальный индекс считает удалённые записи; отчёт по заказам показывает цифры вдвое больше реальных, потому что аналитик забыл про фильтр; а ForeignKey.on_delete=CASCADE тихо игнорирует вашу «мягкость» и удаляет связанные строки по-настоящему. Разбираем, где soft delete в Django протекает и как эти места закрыть.
Материал написан для Django 4.2 LTS и 5.x. Всё, что ниже, работает на обеих ветках; отдельно отмечены места, где поведение зависит от версии. Инвалидация кеша и права доступа к удалённым записям — отдельные темы, здесь только сам механизм удаления и его последствия для ORM и БД.
Наивная реализация и что с ней не так
Минимальный вариант выглядит так и почти всегда попадает в первый коммит:
# Django 4.2 / 5.x
from django.db import models
from django.utils import timezone
class Article(models.Model):
title = models.CharField(max_length=200)
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
def delete(self, using=None, keep_parents=False):
# Вместо реального удаления проставляем метку времени
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
Проблема даже не в этом коде, а в том, что он покрывает ровно один путь удаления — вызов instance.delete(). А удалить объект в Django можно ещё как минимум двумя способами, и оба пройдут мимо вашего метода:
Article.objects.filter(...).delete()— QuerySet.delete() не вызываетdelete()у каждого объекта, он формирует один SQLDELETE. Ваши записи исчезнут по-настоящему.- Каскад по
ForeignKeyсon_delete=CASCADE— когда удаляется родитель, Django удаляет детей на уровне коллектора, минуя переопределённый метод.
Значит, «мягким» надо сделать не только объект, но и менеджер, и QuerySet.
Рабочая реализация: менеджер, QuerySet и базовая модель
Собираем три части: собственный QuerySet с методами мягкого удаления, менеджер, который по умолчанию скрывает удалённое, и абстрактную модель, от которой наследуются нужные сущности.
# Django 4.2 / 5.x
from django.db import models
from django.utils import timezone
class SoftDeleteQuerySet(models.QuerySet):
def delete(self):
# Массовое мягкое удаление одним UPDATE
return super().update(deleted_at=timezone.now())
def hard_delete(self):
# Настоящее удаление, когда оно действительно нужно
return super().delete()
def alive(self):
return self.filter(deleted_at__isnull=True)
def dead(self):
return self.filter(deleted_at__isnull=False)
class SoftDeleteManager(models.Manager):
def get_queryset(self):
# Базовый менеджер отдаёт только живые записи
return SoftDeleteQuerySet(self.model, using=self._db).alive()
class AllObjectsManager(models.Manager):
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db)
class SoftDeleteModel(models.Model):
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
objects = SoftDeleteManager() # только живые
all_objects = AllObjectsManager() # живые + удалённые
class Meta:
abstract = True
def delete(self, using=None, keep_parents=False):
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def hard_delete(self, using=None, keep_parents=False):
return super().delete(using=using, keep_parents=keep_parents)
def restore(self):
self.deleted_at = None
self.save(update_fields=["deleted_at"])
Теперь модель наследуется просто:
class Article(SoftDeleteModel):
title = models.CharField(max_length=200)
Article.objects.all() вернёт только живые записи, Article.all_objects.all() — все. Это ключевое решение: дефолтный менеджер прячет удалённое, а полный доступ живёт под отдельным именем. Обратный вариант (по умолчанию всё, а фильтр вручную) приводит к утечкам удалённых записей в каждый второй запрос.
Главный риск soft delete не в коде удаления, а в том, что о нём забывает каждый, кто пишет запрос через месяц. Поэтому «скрыто по умолчанию» — не удобство, а требование безопасности данных.
Первый менеджер — он же дефолтный
Порядок объявления менеджеров важен. Первый объявленный менеджер становится _default_manager — его использует админка, related-запросы и внутренние механизмы Django. Если сделать первым all_objects, удалённые записи полезут в связанные выборки. Держите objects (скрывающий) первым.
Грабли, которые срабатывают позже
Уникальные индексы считают удалённые записи
Поле unique=True и UniqueConstraint работают на уровне БД и ничего не знают про deleted_at. Удалили пользователя с email="a@b.ru", пытаетесь завести нового с тем же адресом — база возвращает IntegrityError: duplicate key. Решение — частичный уникальный индекс, который учитывает только живые строки. В PostgreSQL это делается через условие в UniqueConstraint (Django 4.2+):
class User(SoftDeleteModel):
email = models.EmailField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["email"],
condition=models.Q(deleted_at__isnull=True),
name="uniq_email_alive",
),
]
Такой индекс есть в PostgreSQL и SQLite (частичные индексы), а вот в MySQL до 8.0 частичные индексы не поддерживаются — там придётся выносить проверку уникальности в приложение или хранить в поле-дискриминаторе. Уберите обычный unique=True с самого поля, иначе оно продолжит блокировать удалённые дубли.
Каскады не станут мягкими сами
on_delete=models.CASCADE реагирует на настоящий DELETE. Если родителя удаляют мягко (через save), каскад вообще не срабатывает — дети остаются живыми и «висят» на удалённом родителе. Если же где-то в коде вызвали hard_delete() или QuerySet.hard_delete(), каскад отработает по-настоящему и снесёт связанные строки без всякой метки. Явного «мягкого каскада» в Django нет. Варианты:
- Мягко удалять детей вручную в переопределённом
delete()родителя — надёжно, но многословно. - Собирать связи через
django.db.models.deletion.Collectorи проходить их самостоятельно — гибко, но это уже внутренний API, который между версиями меняется, сверяйтесь с исходниками своей версии. - Оставить детей живыми и фильтровать по живому родителю в запросах — проще всего, если бизнес-логика это допускает.
Сигналы pre_delete / post_delete молчат
При мягком удалении срабатывает save и его сигналы, а pre_delete и post_delete — нет, потому что реального удаления не происходит. Код очистки файлов, инвалидации кеша или записи в аудит, повешенный на post_delete, не выполнится. Если такая логика нужна, вешайте её на переход deleted_at из NULL в дату, отслеживая изменение в save, либо объявите собственный сигнал.
Растущая таблица и запросы
Удалённые строки не исчезают — таблица растёт вечно. На индексируемых полях это терпимо, но полнотабличные операции и VACUUM в PostgreSQL постепенно дорожают. Держите deleted_at под индексом (в примере выше db_index=True уже стоит), а для больших таблиц предусмотрите фоновую задачу физической очистки записей старше N дней через hard_delete().
Когда soft delete не нужен
| Подход | Плюс | Минус | Когда брать |
|---|---|---|---|
| Soft delete (флаг/метка) | Быстрое восстановление, история под рукой | Ломает уникальность и каскады, таблица растёт | Пользователи, заказы, контент, где нужен undo |
| Настоящий DELETE | Простые запросы, чистая уникальность | Данные не вернуть | Логи, временные и производные данные |
| Архивная таблица | Основная таблица не растёт, уникальность цела | Сложнее восстановление, дублирование схемы | Регуляторное хранение, редкое восстановление |
| django-safedelete (пакет) | Готовые политики каскада | Внешняя зависимость, своя логика менеджеров | Много моделей с мягким удалением сразу |
Если восстановление данных бизнесу не нужно, а на поле есть уникальность — соблазн «на всякий случай пометить» обычно не стоит будущих проблем с индексами. Прежде чем встраивать soft delete в модель, спросите: кто и как часто будет восстанавливать эти записи?
Что проверить перед выкатом
- Дефолтный менеджер (
objects) скрывает удалённое, полный доступ вынесен вall_objects, иobjectsобъявлен первым. - Все уникальные поля переведены на частичный
UniqueConstraintс условиемdeleted_at__isnull=True; обычныйunique=Trueснят. - Проверено поведение каскадов: что происходит с детьми при мягком и при жёстком удалении родителя.
- Логика на
post_deleteперенесена или продублирована на переходdeleted_at. - Есть план физической очистки старых удалённых строк и мониторинг размера таблицы.
Дальше — посмотрите в проде на долю удалённых строк относительно живых и на время самых частых запросов к таблице с фильтром по deleted_at. Если доля мёртвых записей уходит за половину, а восстанавливают их единицы, пора включать фоновую очистку и переоценивать, нужен ли soft delete вообще для этой модели.
Prompt-инженер: Идеальные запросы для Midjourney, ChatGPT и других моделей.
Спросить за 15 ₽Источники: Django docs: Managers, Django docs: UniqueConstraint
Частые вопросы
У меня MySQL, а не PostgreSQL. Частичный уникальный индекс не создаётся.
QuerySet.delete() всё равно удаляет записи по-настоящему. Почему?
Каскадное удаление снесло связанные записи, хотя я делал мягкое удаление. Как так?
А если у меня Django 3.2 — сработает ли UniqueConstraint с condition?
Стоит ли брать готовый пакет вместо своей реализации?
Как быть с админкой Django — она показывает удалённые записи?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.