Serializers anidados writable en DRF

Avanzado
Django
Django
Actualizado: 19/04/2026

El problema

Un cliente envía:

POST /api/pedidos/
{
  "cliente_id": 42,
  "fecha": "2026-04-18",
  "lineas": [
    {"producto_id": 1, "cantidad": 2, "precio_unitario": 100},
    {"producto_id": 3, "cantidad": 1, "precio_unitario": 50}
  ]
}

Quieres crear un pedido con 2 líneas en una sola llamada. Esto es un patrón clásico en APIs REST: cabecera + detalle en un único POST.

DRF soporta serializers anidados para lectura sin problema, pero para escritura hay que sobrescribir create() y update() porque DRF por defecto no sabe cómo persistir hijos.

Serializer anidado de lectura (fácil)

# models.py
class Cliente(models.Model):
    nombre = models.CharField(max_length=100)

class Producto(models.Model):
    nombre = models.CharField(max_length=100)
    precio = models.DecimalField(max_digits=10, decimal_places=2)

class Pedido(models.Model):
    cliente = models.ForeignKey(Cliente, on_delete=models.CASCADE)
    fecha = models.DateField()

class PedidoLinea(models.Model):
    pedido = models.ForeignKey(Pedido, related_name='lineas', on_delete=models.CASCADE)
    producto = models.ForeignKey(Producto, on_delete=models.PROTECT)
    cantidad = models.PositiveIntegerField()
    precio_unitario = models.DecimalField(max_digits=10, decimal_places=2)
# serializers.py
class PedidoLineaSerializer(serializers.ModelSerializer):
    producto_nombre = serializers.CharField(source='producto.nombre', read_only=True)

    class Meta:
        model = PedidoLinea
        fields = ['id', 'producto', 'producto_nombre', 'cantidad', 'precio_unitario']


class PedidoSerializer(serializers.ModelSerializer):
    lineas = PedidoLineaSerializer(many=True, read_only=True)  # solo lectura
    cliente_nombre = serializers.CharField(source='cliente.nombre', read_only=True)

    class Meta:
        model = Pedido
        fields = ['id', 'cliente', 'cliente_nombre', 'fecha', 'lineas']

GET /api/pedidos/1/ devuelve el pedido con sus líneas anidadas. Fácil.

Serializer anidado writable

Para escribir, hay que quitar read_only=True y sobrescribir create():

class PedidoSerializer(serializers.ModelSerializer):
    lineas = PedidoLineaSerializer(many=True)

    class Meta:
        model = Pedido
        fields = ['id', 'cliente', 'fecha', 'lineas']

    def create(self, validated_data):
        lineas_data = validated_data.pop('lineas')
        pedido = Pedido.objects.create(**validated_data)
        for linea_data in lineas_data:
            PedidoLinea.objects.create(pedido=pedido, **linea_data)
        return pedido

Con esto, el POST del JSON anidado funciona: se crea el pedido con sus 2 líneas.

Atomicidad con transaction.atomic

Si alguna línea falla (por ejemplo producto_id no existe), quieres que todo el pedido se revierta. Envuelve en transacción:

from django.db import transaction

class PedidoSerializer(serializers.ModelSerializer):
    lineas = PedidoLineaSerializer(many=True)

    @transaction.atomic
    def create(self, validated_data):
        lineas_data = validated_data.pop('lineas')
        pedido = Pedido.objects.create(**validated_data)
        lineas = [PedidoLinea(pedido=pedido, **l) for l in lineas_data]
        PedidoLinea.objects.bulk_create(lineas)
        return pedido

bulk_create inserta todas las líneas en una sola query SQL (más eficiente que N inserts).

Update con hijos: estrategia de sincronización

Update es más complejo: el cliente puede añadir, modificar o eliminar líneas. Estrategia típica:

@transaction.atomic
def update(self, instance, validated_data):
    lineas_data = validated_data.pop('lineas', None)

    # Actualiza campos del pedido
    for attr, value in validated_data.items():
        setattr(instance, attr, value)
    instance.save()

    if lineas_data is not None:
        self._sync_lineas(instance, lineas_data)

    return instance

def _sync_lineas(self, pedido, lineas_data):
    # IDs enviados por el cliente
    ids_recibidos = [l['id'] for l in lineas_data if 'id' in l]

    # Borra las que no estan en el payload
    pedido.lineas.exclude(id__in=ids_recibidos).delete()

    # Upsert: actualiza o crea
    for linea_data in lineas_data:
        linea_id = linea_data.pop('id', None)
        if linea_id:
            PedidoLinea.objects.filter(id=linea_id, pedido=pedido).update(**linea_data)
        else:
            PedidoLinea.objects.create(pedido=pedido, **linea_data)

Patrón:

  • Líneas con id en el payload: actualizar.
  • Líneas sin id: crear nuevas.
  • Líneas existentes que no aparecen en el payload: eliminar.

Es el mismo modelo que usan los formsets de Django.

Validación cruzada

Ejemplo: la suma de cantidades no puede superar el stock del producto. Valida a nivel de serializer:

class PedidoSerializer(serializers.ModelSerializer):
    lineas = PedidoLineaSerializer(many=True)

    def validate_lineas(self, value):
        # Validacion de la lista completa
        if len(value) == 0:
            raise serializers.ValidationError("Un pedido debe tener al menos una linea.")
        if len(value) > 100:
            raise serializers.ValidationError("Maximo 100 lineas por pedido.")
        return value

    def validate(self, attrs):
        # Validacion cruzada: precio unitario coincide con el del producto
        lineas = attrs.get('lineas', [])
        productos_ids = [l['producto'].id for l in lineas]
        precios_db = {p.id: p.precio for p in Producto.objects.filter(id__in=productos_ids)}

        for linea in lineas:
            prod_id = linea['producto'].id
            if linea['precio_unitario'] != precios_db.get(prod_id):
                raise serializers.ValidationError(
                    f"Precio de producto {prod_id} no coincide con BBDD."
                )
        return attrs

extra_kwargs para configurar campos

Puedes marcar campos como opcionales, writeonly, etc.:

class PedidoSerializer(serializers.ModelSerializer):
    lineas = PedidoLineaSerializer(many=True)

    class Meta:
        model = Pedido
        fields = ['id', 'cliente', 'fecha', 'lineas', 'codigo_cupon']
        extra_kwargs = {
            'codigo_cupon': {'write_only': True, 'required': False},
            'fecha': {'read_only': True},  # generada en backend
        }

Relación OneToOne anidada

Similar para relaciones 1:1:

class PerfilSerializer(serializers.ModelSerializer):
    class Meta:
        model = Perfil
        fields = ['biografia', 'avatar', 'telefono']


class UsuarioSerializer(serializers.ModelSerializer):
    perfil = PerfilSerializer()

    class Meta:
        model = Usuario
        fields = ['id', 'username', 'email', 'perfil']

    def create(self, validated_data):
        perfil_data = validated_data.pop('perfil')
        usuario = Usuario.objects.create(**validated_data)
        Perfil.objects.create(usuario=usuario, **perfil_data)
        return usuario

ManyToMany writable

Para M:M, un patrón típico es recibir IDs:

class PedidoSerializer(serializers.ModelSerializer):
    tags = serializers.PrimaryKeyRelatedField(
        queryset=Tag.objects.all(),
        many=True,
        required=False,
    )

    class Meta:
        model = Pedido
        fields = ['id', 'fecha', 'tags']

Con eso, el cliente envía "tags": [1, 2, 3] y DRF gestiona la relación automáticamente. Si necesitas campos extra en la tabla intermedia (ej. cantidad en un carrito), debes usar un through model explícito.

Rendimiento: select_related y prefetch_related

Lectura anidada ingenua genera N+1 queries:

# MAL: 1 query por pedido + 1 query por cada linea
for pedido in Pedido.objects.all():
    for linea in pedido.lineas.all():
        print(linea)

Corrección en el queryset:

class PedidoViewSet(viewsets.ModelViewSet):
    queryset = Pedido.objects.select_related('cliente').prefetch_related('lineas__producto')
    serializer_class = PedidoSerializer
  • select_related('cliente'): JOIN para FK hacia "arriba" (1 query).
  • prefetch_related('lineas__producto'): 2 queries extra (una para lineas, otra para productos), unidas en Python.

Con esto, listar 1000 pedidos son 3-4 queries en lugar de 3000+.

Patrón con WritableNestedSerializer (drf-writable-nested)

pip install drf-writable-nested

Si los patrones anteriores se repiten mucho, la librería drf-writable-nested automatiza create + update + delete de anidados con un mixin:

from drf_writable_nested import WritableNestedModelSerializer

class PedidoSerializer(WritableNestedModelSerializer):
    lineas = PedidoLineaSerializer(many=True)

    class Meta:
        model = Pedido
        fields = ['id', 'cliente', 'fecha', 'lineas']

Sin create() ni update() explícitos, funciona el POST y PATCH con anidados. Usar con cuidado (menos control, puede ocultar bugs).

Documentación con drf-spectacular

Los anidados aparecen en el schema OpenAPI automáticamente. Si tienes un serializer distinto para escritura que para lectura:

from drf_spectacular.utils import extend_schema

class PedidoViewSet(viewsets.ModelViewSet):
    @extend_schema(
        request=PedidoWriteSerializer,
        responses={201: PedidoReadSerializer},
    )
    def create(self, request, *args, **kwargs):
        return super().create(request, *args, **kwargs)

Errores comunes

  • No usar transaction.atomic: si falla a media creación, quedan datos corruptos.
  • No filtrar por queryset al hacer update: un usuario podría actualizar una línea de otro pedido.
  • N+1 queries al serializar: siempre prefetch_related en el queryset del ViewSet.
  • Retornar el serializer con many=True sin optimizar: lista de 1000 pedidos sin prefetch es la muerte.
  • Confundir IDs nuevos y existentes: si el cliente envía {"id": 999} que no existe, debe ser 404 no create.
  • Permitir al cliente definir el ID del hijo: puede colisionar con otro pedido. Valida ownership del hijo.

Checklist

  • [ ] transaction.atomic en create y update de anidados.
  • [ ] prefetch_related y select_related en el queryset.
  • [ ] Validación de longitud mínima/máxima de la lista.
  • [ ] Validación cruzada con validate().
  • [ ] Sync en update (insert/update/delete) coherente.
  • [ ] Tests que cubren los 3 casos: add, update, remove.
  • [ ] drf-spectacular documenta request y response si son distintos.
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

Declarar un serializer anidado de solo lectura y de escritura. Sobrescribir create() para extraer datos anidados y crear objetos relacionados. Sobrescribir update() manteniendo IDs existentes vs creando nuevos hijos. Usar transaction.atomic para garantizar atomicidad. Validar datos cruzados con validate(). Gestionar el caso de formulario con muchas filas (formsets en API).

Cursos que incluyen esta lección

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