Мягкое удаление в Django: чек-лист рабочих приёмов
Флаг is_deleted кажется безобидным, пока не всплывают дубли в уникальных полях, утечки в related-запросах и объекты, которые «не удалились». Разбираем, что учесть при мягком удалении в Django 5.x.

Мягкое удаление — это подмена реального DELETE установкой флага вроде is_deleted=True или отметки времени deleted_at. Задача звучит просто: не терять данные, уметь восстановить, показывать пользователю «удалено». На практике первое же уникальное поле, первый ForeignKey с каскадом и первый QuerySet без фильтра превращают это в источник тихих багов. Ниже — приёмы для Django 5.x с ценой каждого варианта и чек-лист, по которому стоит пройтись перед тем, как выкатывать это в прод.
Почему objects.filter(is_deleted=False) везде — это ловушка
Первое очевидное решение: добавить булево поле и вручную дописывать .filter(is_deleted=False) в каждый запрос. Работает ровно до момента, пока кто-то не забудет. А забудут обязательно: в сериализаторе DRF, в get_object_or_404, в related-выборке order.items.all(), в админке. Один пропущенный фильтр — и «удалённая» запись вылезает в списке или в API.
Корень проблемы в том, что фильтрация размазана по коду и не централизована. В Django для этого есть штатное место — менеджер модели и его QuerySet.
Три подхода и их цена
1. Кастомный менеджер по умолчанию
Переопределяем objects так, чтобы он по умолчанию исключал удалённое, и держим второй менеджер для полного доступа.
from django.db import models
from django.utils import timezone
class SoftDeleteQuerySet(models.QuerySet):
def alive(self):
return self.filter(deleted_at__isnull=True)
def dead(self):
return self.filter(deleted_at__isnull=False)
def delete(self):
# bulk-мягкое удаление: не дергаем сигналы, обновляем одним UPDATE
return super().update(deleted_at=timezone.now())
def hard_delete(self):
return super().delete()
class AliveManager(models.Manager):
def get_queryset(self):
return SoftDeleteQuerySet(self.model, using=self._db).alive()
class SoftDeleteModel(models.Model):
deleted_at = models.DateTimeField(null=True, blank=True, db_index=True)
objects = AliveManager() # по умолчанию только живые
all_objects = models.Manager() # полный доступ, включая удалённые
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):
super().delete(using=using, keep_parents=keep_parents)
def restore(self):
self.deleted_at = None
self.save(update_fields=["deleted_at"])
Выигрыш: обычный Model.objects.filter(...) уже чист. Цена: первый менеджер модели становится менеджером по умолчанию — он используется для reverse-связей и в некоторых внутренних операциях. Это может внезапно спрятать объекты там, где вы этого не ждали (см. раздел о граничных случаях).
2. Явные менеджеры без подмены поведения по умолчанию
Оставляем objects стандартным, а фильтрацию выносим в отдельный менеджер alive. Ничего не прячется само, но и защиты от забытого фильтра нет — ответственность на разработчике.
Когда брать: если в проекте много bulk-операций и вам важно, чтобы Model.objects вёл себя предсказуемо и видел всё.
3. django-soft-delete / django-safedelete как готовая библиотека
Пакет вроде django-safedelete даёт готовые базовые классы и политики каскада. Выигрыш: не пишете велосипед, есть настройка поведения каскадного удаления. Цена: ещё одна зависимость, чужие соглашения об именах полей и менеджерах, версионная совместимость с вашей Django. Перед установкой проверьте в README пакета, какие версии Django он поддерживает — не подставляйте это по памяти.
| Подход | Забытый фильтр | Bulk-удаление | Каскад по FK | Зависимости |
|---|---|---|---|---|
| Менеджер по умолчанию | защищено | своя реализация | вручную | нет |
| Явный менеджер alive | на разработчике | штатное поведение objects | вручную | нет |
| Библиотека | защищено | из коробки | настраивается политикой | да |
Чек-лист перед выкаткой
- Уникальные поля. Обычный
unique=Trueнаemailне даст создать новую запись с тем же значением, пока «удалённая» лежит в таблице. Решение — частичный уникальный индекс черезUniqueConstraintс условием. - Related-запросы.
order.items.all()иprefetch_relatedидут через менеджер связанной модели. Проверьте, что они отдают только живые записи — или что вы сознательно хотите видеть все. - Каскад. Стандартный
on_delete=models.CASCADEпри мягком удалении не срабатывает: вы же не вызываете реальныйDELETE. Дочерние объекты остаются «живыми» под удалённым родителем. Решите это явно. - Админка. По умолчанию
ModelAdminиспользует менеджер по умолчанию. Если вам нужно видеть удалённое в админке — переопределитеget_queryset. - DRF-сериализаторы и
get_object_or_404. Убедитесь, что они бьют по нужному менеджеру. - Индекс на поле-флаг. Если по
deleted_atфильтруется каждый запрос, поставьтеdb_index=True— иначе на большой таблице получите лишние сканы.
Уникальность только среди живых записей
from django.db import models
class User(SoftDeleteModel):
email = models.EmailField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["email"],
condition=models.Q(deleted_at__isnull=True),
name="unique_email_alive",
)
]
Частичный индекс * накладывает уникальность только на строки, где deleted_at IS NULL. Удалённые записи из-под ограничения выпадают, и вы можете завести нового пользователя с тем же email. Поддерживается в PostgreSQL и SQLite; для MySQL частичные индексы через condition недоступны — проверьте это ограничение под вашу СУБД.
Мягкое удаление не бесплатное. Вы меняете реальный DELETE на вечно растущую таблицу с флагом и берёте на себя всё, что раньше делала СУБД: каскад, уникальность, очистку. Если данные восстанавливать не придётся — возможно, вам нужен не soft delete, а аудит-лог.
Где подход ломается
Менеджер по умолчанию прячет слишком много
Когда фильтрующий менеджер стоит первым, Django использует его для обратных связей и при загрузке объектов в некоторых внутренних сценариях. Это значит, что parent.children.all() отдаст только живых детей — иногда это ровно то, что нужно, а иногда вы неожиданно теряете доступ к данным при отчётах и миграциях. Держите второй менеджер all_objects и помните о нём.
Каскад остаётся вашей проблемой
При мягком удалении родителя Django не тронет дочерние строки — on_delete вызывается только при настоящем удалении. Варианты: обходить связи вручную в переопределённом delete(), ставить дочерним объектам тот же deleted_at, либо на уровне запросов всегда джойнить состояние родителя. Первый путь дорог по запросам на глубоких деревьях, третий усложняет каждый QuerySet. Выбор зависит от того, насколько часто вы читаете дочерние объекты в отрыве от родителя.
Таблица не чистится сама
«Удалённые» строки копятся. На таблице с миллионами записей это раздувает индексы и замедляет запросы даже с частичным индексом. Заведите периодическую задачу (Celery beat, cron-команда управления), которая физически удаляет то, что помечено давно и точно не понадобится — например, старше срока хранения по вашей политике.
Массовые операции обходят Model.delete()
Вызов QuerySet.delete() не проходит через переопределённый метод модели — Django выполняет удаление на уровне SQL. Именно поэтому в примере выше delete() переопределён и на уровне QuerySet. Проверьте, что все места bulk-удаления идут через ваш QuerySet, а не через сырой SQL или _base_manager.
Что сделать после
Прогоните существующий код по чек-листу: найдите все .delete(), все обращения к related-менеджерам и все уникальные ограничения. Добавьте в тесты явный кейс «удалённый объект не виден через objects, но виден через all_objects» — это дешёвая страховка от регрессий при обновлении Django. В проде смотрите на рост таблиц с мягким удалением и долю строк с непустым deleted_at: если она перевалила за половину, пора либо чистить, либо пересматривать саму необходимость хранить это в основной таблице.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Django documentation: Managers, Django documentation: UniqueConstraint
Частые вопросы
У меня Django 4.2, а не 5.x — приёмы применимы?
Что делать, если удалённые записи всё равно вылезают в API?
Почему on_delete=CASCADE не удаляет дочерние объекты при мягком удалении?
Мягкое удаление сильно замедлит запросы?
Стоит ли брать готовую библиотеку или писать своё?
Как быть с уникальным email, если удалённый пользователь занял его навсегда?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.