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}"endelete). - Usar
UniqueConstraint(condition=Q(deleted__isnull=True))enMeta.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 NULLanade ruido. - Mas storage: la tabla nunca decrece. Mitigalo con purga periodica.
- Indices: anade indices parciales
WHERE deleted IS NULLpara 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
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