El hack del ORM que nadie te enseña: cómo detectar y eliminar las N+1 queries
Nivel: Intermedio · Tiempo: 15 minutos · Impacto: Inmediato en producción Prerequisito: Haber completado el Curso de Django en Platzi
🚨 El Problema: ¿Qué es una Query N+1?
Imagina que tienes el proyecto CoffeeShop del curso y quieres listar todas las órdenes con el nombre del cliente que las hizo.
Haces esto en tu vista y parece funcionar perfecto:
# views.py — Esto SE VE bien, pero ES un desastre silencioso
def lista_ordenes(request):
ordenes = Order.objects.all() # 1 query
return render(request, 'ordenes.html', {'ordenes': ordenes})
<!-- ordenes.html -->
{% for orden in ordenes %}
<p>{{ orden.user.username }} — {{ orden.fecha }}</p> <!-- +1 query por cada orden 😱 -->
{% endfor %}
¿Qué pasa internamente?
AcciónQueries ejecutadas
Order.objects.all()
1 query
Acceder a orden.user en cada iteración
1 query × N órdenes
Total con 100 órdenes
101 queries 💀
Total con 1,000 órdenes
1,001 queries 💀💀💀
Tu app funciona en desarrollo con 5 registros. Llega a producción con miles y explota.
🛠️ Paso 1: Instala django-debug-toolbar para VER el problema
Primero necesitas ver el monstruo antes de matarlo.
pip install django-debug-toolbar
Agrega esto a tu settings.py:
# settings.py
INSTALLED_APPS = [
# ... tus apps existentes ...
'debug_toolbar', # 👈 agrega esto
]
MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware', # 👈 al inicio del middleware
# ... resto de middlewares ...
]
# Permite que la toolbar se muestre en localhost
INTERNAL_IPS = [
'127.0.0.1',
]
Agrega la URL en tu urls.py principal:
# urls.py
from django.conf import settings
urlpatterns = [
# ... tus URLs existentes ...
]
# Solo activa la toolbar en desarrollo
if settings.DEBUG:
import debug_toolbar
urlpatterns = [
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns
✅ Resultado: Ahora verás un panel lateral en tu app con el número exacto de queries por página.
💀 Paso 2: Reproduce el problema y ve el horror
Con la toolbar activa, entra a tu vista de órdenes.
En el panel verás algo como esto:
SQL Queries: 47 queries in 320ms
Haz clic y verás 47 queries casi idénticas como:
SELECT * FROM auth_user WHERE id = 1;
SELECT * FROM auth_user WHERE id = 2;
SELECT * FROM auth_user WHERE id = 3;
-- ... 44 veces más 😭
Esto es el problema N+1 en vivo. Ahora lo matamos.
⚡ Paso 3: El Fix — select_related y prefetch_related
Regla de oro:
Tipo de relaciónSolución
ForeignKey / OneToOneField
select_related()
ManyToManyField / reverse FK
prefetch_related()
Fix para ForeignKey (relación directa):
# views.py — ANTES 💀
def lista_ordenes(request):
ordenes = Order.objects.all()
return render(request, 'ordenes.html', {'ordenes': ordenes})
# views.py — DESPUÉS ⚡
def lista_ordenes(request):
# select_related hace un SQL JOIN y trae todo en UNA sola query
ordenes = Order.objects.select_related('user').all()
return render(request, 'ordenes.html', {'ordenes': ordenes})
Resultado en la toolbar:
ANTES: 47 queries en 320ms 💀
DESPUÉS: 1 query en 18ms ⚡
Fix para ManyToMany (ej: productos de una orden):
# views.py — ANTES 💀
def detalle_orden(request, orden_id):
orden = Order.objects.get(id=orden_id)
# Acceder a orden.products.all() en el template genera N queries
return render(request, 'detalle.html', {'orden': orden})
# views.py — DESPUÉS ⚡
def detalle_orden(request, orden_id):
# prefetch_related hace una segunda query optimizada y cachea los resultados
orden = Order.objects.prefetch_related('products').get(id=orden_id)
return render(request, 'detalle.html', {'orden': orden})
El combo definitivo (relaciones encadenadas):
# Cuando necesitas acceder a user y a los productos al mismo tiempo
ordenes = Order.objects.select_related('user').prefetch_related('products').all()
Con esto le dices a Django: "Tráeme las órdenes, sus usuarios y sus productos, todo de una vez."
🎯 Paso 4: Aplícalo en el proyecto CoffeeShop del curso
En el proyecto del curso tienes el modelo Order con relación a User. Aquí está el before/after completo:
# orders/views.py — Versión optimizada lista para producción
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import ListView
from .models import Order
class MyOrdersView(LoginRequiredMixin, ListView):
model = Order
template_name = 'orders/my_orders.html'
context_object_name = 'orders'
def get_queryset(self):
return (
Order.objects
.select_related('user') # 👈 trae el usuario en el JOIN
.prefetch_related('products') # 👈 trae los productos en batch
.filter(user=self.request.user)
.order_by('-created_at')
)
🏆 Resultado Final
MétricaSin optimizarOptimizado
Queries en lista de órdenes
47
1
Tiempo de respuesta
~320ms
~18ms
Escalabilidad
Muere con 1k registros
Aguanta millones
💡 Tip Extra (el que pocos conocen)
Puedes usar only() y defer() para traer solo las columnas que necesitas y reducir aún más el peso de cada query:
# Solo trae id, fecha y el username del user — ignora todo lo demás
ordenes = (
Order.objects
.select_related('user')
.only('id', 'created_at', 'user__username') # 👈 columnas exactas
.filter(user=request.user)
)
Ideal para vistas de listado donde no necesitas todos los campos del modelo.
📋 Resumen: Tu Checklist Anti N+1
- [ ] Instala
django-debug-toolbaren todos tus proyectos de desarrollo - [ ] Revisa que ninguna vista tenga más de 5-10 queries
- [ ] Usa
select_related()en cadaForeignKeyque accedas en el template - [ ] Usa
prefetch_related()en cadaManyToManyFieldo reverse FK - [ ] Combínalos cuando tengas relaciones múltiples
- [ ] Usa
only()en vistas de listado para máxima performance
🚀 ¿Quieres ir más lejos? Explora
django-silkpara profiling avanzado en producción yQuerySet.explain()para analizar el plan de ejecución de tus queries directamente desde Django.
Tutorial basado en el Curso de Django de Platzi — Nivel intermedio
Curso de Django
COMPARTE ESTE ARTÍCULO Y MUESTRA LO QUE APRENDISTE




