Performance y queries N+1 en DRF

Avanzado
Django
Django
Actualizado: 19/04/2026

El problema N+1 explicado

Una API aparentemente simple puede disparar cientos de queries:

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

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


# views.py
class PedidoViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Pedido.objects.all()
    serializer_class = PedidoSerializer

Al listar 100 pedidos:

  • 1 query para Pedido.objects.all().
  • 100 queries para cliente.nombre (una por pedido).
  • 100 queries para pedido.lineas.all() (una por pedido).
  • N queries extra para linea.producto.nombre dentro de PedidoLineaSerializer.

Total: 300+ queries para listar 100 pedidos. A 10ms por query son 3 segundos. En producción con carga: timeout.

Este es el problema N+1.

Solución 1: select_related (para FKs)

select_related hace JOIN en SQL y trae los objetos relacionados en una sola query. Funciona para ForeignKey y OneToOneField:

class PedidoViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Pedido.objects.select_related('cliente')  # JOIN con cliente
    serializer_class = PedidoSerializer

Ahora pedido.cliente.nombre no dispara query: ya vino en el SELECT. 100 queries ahorradas.

Para FKs en cadena:

queryset = Linea.objects.select_related('pedido__cliente__direccion')
# JOIN con pedido, cliente y direccion, todo en una query

Solución 2: prefetch_related (para M2M e inversos)

prefetch_related hace 2 queries separadas y las combina en Python. Funciona para ManyToMany y reverse ForeignKey (las "collections"):

class PedidoViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = (
        Pedido.objects
        .select_related('cliente')
        .prefetch_related('lineas__producto')  # 2 queries extra: lineas + productos
    )
    serializer_class = PedidoSerializer

Queries ejecutadas:

  1. SELECT * FROM pedido JOIN cliente
  2. SELECT * FROM pedidolinea WHERE pedido_id IN (...)
  3. SELECT * FROM producto WHERE id IN (...)

Total: 3 queries para listar 100 pedidos con líneas y productos, en lugar de 300+.

Prefetch con filtro (to_attr)

A veces quieres precargar solo ciertas relaciones. Usa Prefetch:

from django.db.models import Prefetch

queryset = Pedido.objects.prefetch_related(
    Prefetch(
        'lineas',
        queryset=PedidoLinea.objects.filter(cantidad__gt=0).order_by('-cantidad'),
        to_attr='lineas_positivas',  # accesible como pedido.lineas_positivas
    )
)

En el serializer:

class PedidoSerializer(serializers.ModelSerializer):
    lineas_positivas = PedidoLineaSerializer(many=True, read_only=True)

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

annotate para agregados

Calcular totales desde Python recorre todos los hijos:

# MAL: python suma en cada serializer, con N+1
class PedidoSerializer(serializers.ModelSerializer):
    total = serializers.SerializerMethodField()

    def get_total(self, obj):
        return sum(l.cantidad * l.precio_unitario for l in obj.lineas.all())

Mejor con annotate:

from django.db.models import F, Sum, DecimalField, ExpressionWrapper

queryset = Pedido.objects.annotate(
    total=Sum(
        ExpressionWrapper(
            F('lineas__cantidad') * F('lineas__precio_unitario'),
            output_field=DecimalField(),
        )
    )
)
class PedidoSerializer(serializers.ModelSerializer):
    total = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True)

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

El total se calcula en SQL. Para 100 pedidos es 1 query con GROUP BY, no 100 iteraciones en Python.

only y defer: limitar columnas

Por defecto, Django trae todas las columnas. Si solo necesitas algunas:

queryset = Pedido.objects.only('id', 'fecha', 'cliente_id').select_related('cliente')

Ahorra transferencia de red y memoria si la tabla tiene muchas columnas (BLOBs, text largo).

defer es lo opuesto: trae todo excepto lo que especifiques:

queryset = Producto.objects.defer('descripcion_larga', 'metadata_json')

Cuidado: acceder a un campo defered dispara una query extra.

Caso completo: lista de pedidos optimizada

class PedidoViewSet(viewsets.ReadOnlyModelViewSet):
    serializer_class = PedidoSerializer

    def get_queryset(self):
        return (
            Pedido.objects
            .filter(tenant=self.request.user.tenant)
            .select_related('cliente', 'cliente__direccion_default')
            .prefetch_related(
                Prefetch(
                    'lineas',
                    queryset=PedidoLinea.objects
                             .select_related('producto', 'producto__categoria')
                             .order_by('orden'),
                ),
            )
            .annotate(
                total_lineas=Count('lineas'),
                importe_total=Sum(F('lineas__cantidad') * F('lineas__precio_unitario')),
            )
            .order_by('-fecha')
        )

Total de queries: 3-4 para listar cualquier número de pedidos. Rendimiento constante.

Detectar N+1 con django-debug-toolbar

En desarrollo:

pip install django-debug-toolbar
# settings.py (solo DEBUG)
INSTALLED_APPS += ['debug_toolbar']
MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware']
INTERNAL_IPS = ['127.0.0.1']

En cada petición aparece el panel SQL con todas las queries ejecutadas y su tiempo. Busca:

  • Queries duplicadas (mismo SQL con distintos parámetros): síntoma de N+1.
  • Queries lentas (> 100 ms): necesitan índice.
  • Total > 20 queries para un endpoint simple: revisar optimización.

django-silk: profiling en producción

django-debug-toolbar no va en producción. Para profiling en staging o producción:

pip install django-silk
# settings.py
INSTALLED_APPS += ['silk']
MIDDLEWARE += ['silk.middleware.SilkyMiddleware']
SILKY_AUTHENTICATION = True
SILKY_AUTHORISATION = True
SILKY_PERMISSIONS = lambda user: user.is_superuser
SILKY_META = True

Accede a /silk/ y ves todas las requests con sus queries, tiempos, stack traces. Muy útil para diagnosticar N+1 en entornos reales.

Perseguir queries duplicadas con nplusone

pip install nplusone
# settings.py (solo DEBUG)
INSTALLED_APPS += ['nplusone.ext.django']
MIDDLEWARE += ['nplusone.ext.django.NPlusOneMiddleware']
NPLUSONE_LOGGER = logging.getLogger('nplusone')
NPLUSONE_LOG_LEVEL = logging.WARN
NPLUSONE_RAISE = True  # levanta excepcion cuando detecta N+1

Con NPLUSONE_RAISE = True, cualquier N+1 rompe el test. Usar en CI para prevenir regresiones.

Patrones comunes que causan N+1

SerializerMethodField con consulta

# MAL: 1 query por pedido
class PedidoSerializer(serializers.ModelSerializer):
    ultima_factura = serializers.SerializerMethodField()

    def get_ultima_factura(self, obj):
        return obj.facturas.order_by('-fecha').first().numero

Soluciones:

  • Opción 1: usar Prefetch con to_attr='ultima_factura_prefetch'.
  • Opción 2: annotate(ultima_factura_numero=Subquery(Factura.objects.filter(pedido=OuterRef('pk')).order_by('-fecha').values('numero')[:1])).

Acceso a propiedad del modelo con query

# models.py
class Pedido(models.Model):
    @property
    def importe_total(self):
        return self.lineas.aggregate(total=Sum(...))  # query por cada pedido

Si la usas en serializer, explota. Mueve el cálculo al annotate del queryset.

ManyRelatedManager en template/serializer

class AutorSerializer(serializers.ModelSerializer):
    libros = LibroSerializer(many=True, read_only=True)  # N+1 si no hay prefetch_related

Siempre prefetch_related('libros') en el ViewSet.

Connection pooling

Incluso con pocas queries, abrir/cerrar conexiones es caro:

# settings.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        ...
        'CONN_MAX_AGE': 600,  # reutiliza conexion 10min
    }
}

Con CONN_MAX_AGE = 0 (default) Django abre una nueva por cada request. Con 600 las reutiliza durante 10 minutos.

Para pooling "real" (requerido con muchos workers), usar pgbouncer entre Django y PostgreSQL.

Query compilation cache

Django cachea las queries compiladas automáticamente. No hay nada que hacer. Si ves queries idénticas con distintos params que tardan distinto, probablemente es prepared statements de PostgreSQL, no un problema de Django.

Checklist

  • [ ] django-debug-toolbar en desarrollo.
  • [ ] Todos los ViewSets con select_related/prefetch_related explícito.
  • [ ] Agregados con annotate, no en Python.
  • [ ] SerializerMethodField solo para lógica simple sin queries extra.
  • [ ] Tests con nplusone o assertNumQueries para detectar regresiones.
  • [ ] django-silk en staging para profiling real.
  • [ ] CONN_MAX_AGE = 600 en settings.
  • [ ] pgbouncer o similar en producción con muchos workers.
  • [ ] Índices en columnas de WHERE/ORDER BY más usados.
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

Identificar el problema N+1 en logs de SQL o con django-debug-toolbar. Usar select_related(fk) para FKs y OneToOne. Usar prefetch_related(related_name) para M2M e inversos. Usar Prefetch(queryset=..., to_attr=...) para queries filtradas sin N+1. Combinar annotate para agregados sobre relaciones. Usar only() y defer() para limitar columnas. Activar django-silk en desarrollo para profiling SQL.

Cursos que incluyen esta lección

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