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
iden 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_relateden el queryset del ViewSet. - Retornar el serializer con
many=Truesin 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.atomicen create y update de anidados. - [ ]
prefetch_relatedyselect_relateden 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
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