Soft delete en DRF con django-safedelete

Avanzado
Django
Django
Actualizado: 19/04/2026

Por que soft delete

Producto.objects.get(pk=42).delete() ejecuta un DELETE FROM productos WHERE id = 42. Pasa al instante y queda irreversible. Si el cliente llama por error y borra todos sus pedidos, no hay vuelta atras (mas que de un backup).

Soft delete reemplaza el DELETE por un UPDATE productos SET deleted_at = now() WHERE id = 42. El registro sigue en la tabla pero los queries normales no lo ven. Lo recuperas con un simple UPDATE deleted_at = NULL.

Casos donde es practicamente obligatorio:

  • Compliance GDPR: el borrado del derecho al olvido suele tener un periodo de gracia (15-30 dias) por si fue un error.
  • Auditoria financiera: las facturas no se pueden borrar; se anulan.
  • Plataformas SaaS multi-tenant: si un usuario se da de baja, sus datos se conservan en periodo de gracia para que pueda reactivarse.
  • Soporte a usuarios: "borre mi pedido por error, restauralo" es una peticion comun.
  • E-commerce: descatalogar un producto sin perder el historial de pedidos que lo referencian.

django-safedelete

Es la libreria de referencia. Encapsula el patron con manager, queryset y politicas de cascada.

pip install django-safedelete
# productos/models.py
from safedelete.models import SafeDeleteModel
from safedelete.config import SOFT_DELETE_CASCADE

class Producto(SafeDeleteModel):
    _safedelete_policy = SOFT_DELETE_CASCADE

    nombre = models.CharField(max_length=200)
    precio = models.DecimalField(max_digits=10, decimal_places=2)

    def __str__(self):
        return self.nombre

SafeDeleteModel anade automaticamente el campo deleted (un DateTimeField nullable) a la tabla. Migra:

python manage.py makemigrations
python manage.py migrate

Politicas disponibles: SOFT_DELETE (solo el objeto), SOFT_DELETE_CASCADE (objeto + dependientes), HARD_DELETE (borrado fisico inmediato), HARD_DELETE_NOCASCADE (no permite borrar si hay dependencias).

Comportamiento del manager

Producto.objects.all()                        # NO incluye borrados
Producto.objects.all_with_deleted()           # incluye borrados
Producto.objects.deleted_only()               # SOLO borrados (la papelera)
Producto.objects.get(pk=42)                   # ignora borrados
Producto.objects.all_with_deleted().get(pk=42)  # busca tambien en borrados

p.delete() por defecto hace soft delete. Para borrar fisicamente:

p.delete(force_policy=HARD_DELETE)

Integrar en DRF: borrado normal y endpoint trash

# productos/views.py
from rest_framework.viewsets import ModelViewSet
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAdminUser
from safedelete.config import HARD_DELETE
from productos.models import Producto
from productos.serializers import ProductoSerializer

class ProductoViewSet(ModelViewSet):
    serializer_class = ProductoSerializer

    def get_queryset(self):
        # Por defecto solo no borrados
        return Producto.objects.all()

    @action(detail=False, methods=["get"], url_path="trash")
    def trash(self, request):
        qs = Producto.objects.deleted_only()
        page = self.paginate_queryset(qs)
        ser  = self.get_serializer(page, many=True)
        return self.get_paginated_response(ser.data)

    @action(detail=True, methods=["post"], url_path="restore")
    def restore(self, request, pk=None):
        producto = Producto.objects.all_with_deleted().filter(pk=pk).first()
        if not producto:
            return Response({"error": "no encontrado"}, status=status.HTTP_404_NOT_FOUND)
        if not producto.deleted:
            return Response({"error": "no esta borrado"}, status=status.HTTP_400_BAD_REQUEST)
        producto.undelete()
        return Response(self.get_serializer(producto).data)

    @action(
        detail=True, methods=["delete"], url_path="hard-delete",
        permission_classes=[IsAdminUser],
    )
    def hard_delete(self, request, pk=None):
        producto = Producto.objects.all_with_deleted().filter(pk=pk).first()
        if not producto:
            return Response({"error": "no encontrado"}, status=status.HTTP_404_NOT_FOUND)
        producto.delete(force_policy=HARD_DELETE)
        return Response(status=status.HTTP_204_NO_CONTENT)

Cascada y SafeDeleteRelations

Por defecto un ForeignKey a un SafeDeleteModel con on_delete=models.CASCADE puede ejecutar hard delete de los hijos cuando se hace soft delete del padre, lo que rompe el patron.

Solucion: marca tambien el modelo hijo como SafeDeleteModel y elige la politica adecuada en el padre.

class Linea(SafeDeleteModel):
    _safedelete_policy = SOFT_DELETE_CASCADE
    pedido = models.ForeignKey(Pedido, on_delete=models.CASCADE)
    cantidad = models.PositiveIntegerField()

Con SOFT_DELETE_CASCADE en el padre, al borrar Pedido.delete() se marca deleted_at en pedido y en sus lineas. Un restore() posterior tambien las restaura.

Politica unique con borrados

Si tienes unique=True en slug y borras un producto, intentar crear otro con el mismo slug falla porque el viejo sigue en la tabla. Tres soluciones:

  • Usar unique_together = (("slug", "deleted"),) para permitir un slug repetido si los anteriores estan borrados.
  • Renombrar el slug al borrar (slug = f"{slug}__deleted__{ts}" en delete).
  • Usar UniqueConstraint(condition=Q(deleted__isnull=True)) en Meta.constraints (Django 3.1+).
class Meta:
    constraints = [
        models.UniqueConstraint(
            fields    = ["slug"],
            condition = models.Q(deleted__isnull=True),
            name      = "uniq_slug_no_borrado",
        )
    ]

El indice condicional es la opcion limpia. Funciona en Postgres y SQLite.

GDPR: borrado retardado

Combinacion clasica:

  • El usuario solicita "borra mi cuenta" -> soft delete inmediato.
  • Tras N dias (15-30) un job purga los registros con deleted__lt=now()-timedelta(days=N).
  • En esos N dias el usuario puede reactivar la cuenta.
# users/tasks.py
from celery import shared_task
from datetime import timedelta
from django.utils import timezone
from safedelete.config import HARD_DELETE
from users.models import Usuario

@shared_task
def purgar_usuarios_borrados():
    limite = timezone.now() - timedelta(days=30)
    qs = Usuario.objects.deleted_only().filter(deleted__lt=limite)
    for u in qs:
        u.delete(force_policy=HARD_DELETE)

Diagrama del ciclo de vida

stateDiagram-v2
    [*] --> activo : create
    activo --> borrado : delete (soft)
    borrado --> activo : restore (undelete)
    borrado --> [*] : delete (hard) tras N dias
    activo --> [*] : delete (hard) admin

Filtros y serializers

Si quieres exponer el campo deleted o filtrar por estado, anadelo al serializer:

class ProductoSerializer(serializers.ModelSerializer):
    deleted = serializers.DateTimeField(read_only=True)

    class Meta:
        model  = Producto
        fields = ["id", "nombre", "precio", "deleted"]
import django_filters as df

class ProductoFilter(df.FilterSet):
    incluir_borrados = df.BooleanFilter(method="filter_incluir_borrados")

    class Meta:
        model  = Producto
        fields = ["categoria"]

    def filter_incluir_borrados(self, queryset, name, value):
        if value:
            return Producto.objects.all_with_deleted()
        return queryset

Trade-offs honestos

  • Mas complejidad: cada query implicita WHERE deleted IS NULL anade ruido.
  • Mas storage: la tabla nunca decrece. Mitigalo con purga periodica.
  • Indices: anade indices parciales WHERE deleted IS NULL para queries hot path.
  • Reportes: si haces COUNT(*) FROM productos, sin querer cuentas borrados. Usa siempre el manager.

Soft delete es una herramienta, no una bala de plata. Aplicalo a tablas con datos valiosos (pedidos, facturas, usuarios). En tablas operacionales (tokens, sesiones, logs) el hard delete sigue siendo la opcion correcta.

Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Django es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Django

Explora más contenido relacionado con Django y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Comprender cuando soft delete es necesario (auditoria, GDPR, recuperacion). Instalar django-safedelete y aplicar SafeDeleteModel a tus modelos. Usar all_with_deleted, deleted_only y restore. Anadir endpoints DRF para listar trash y restaurar registros. Diferenciar hard delete y soft delete en permisos. Manejar cascadas: SOFT_DELETE_CASCADE en relaciones FK. Combinar con la solicitud de borrado GDPR retardada.

Cursos que incluyen esta lección

Esta lección forma parte de los siguientes cursos estructurados con rutas de aprendizaje