¿select_related() o prefetch_related()? Ambos métodos eliminan consultas N+1 en Django, pero no generan en absoluto el mismo SQL. El primero añade un join a la consulta principal. El segundo ejecuta varias consultas y después relaciona los objetos en Python. La elección correcta depende menos del volumen de datos que del tipo de relación recorrida.
Para entenderlo, partamos de un modelo deliberadamente sencillo:
from django.conf import settings
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="posts",
)
tags = models.ManyToManyField("Tag", related_name="posts")
class Comment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name="comments",
)
body = models.TextField()
is_public = models.BooleanField(default=True)
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
class Tag(models.Model):
name = models.CharField(max_length=50)
Desde un Post, author designa como máximo a un usuario. En cambio, comments y tags pueden contener varios objetos. Esta diferencia de cardinalidad dicta casi siempre qué método utilizar.
El problema N+1 en Django
Este bucle parece inofensivo:
posts = Post.objects.all()
for post in posts:
print(post.title, post.author.username)
Django empieza cargando los artículos:
SELECT id, title, author_id
FROM blog_post;
Después, el acceso a post.author lanza una consulta por cada artículo:
SELECT id, username
FROM auth_user
WHERE id = 12;
SELECT id, username
FROM auth_user
WHERE id = 37;
-- Una nueva consulta por cada artículo
Con 100 artículos, se obtienen 101 consultas: una para la lista y 100 para los autores. Aunque varios artículos compartan el mismo autor, cada instancia de Post posee su propia caché de relación. Por tanto, Django no comparte automáticamente estos accesos.
Este N+1 puede permanecer invisible en una vista. Suele aparecer más adelante, en una plantilla, una propiedad del modelo o un serializer de DRF.
select_related() genera un join SQL
select_related() carga la relación en la consulta principal:
posts = Post.objects.select_related("author")
for post in posts:
print(post.title, post.author.username)
Para la ForeignKey no nullable de nuestro ejemplo, Django genera una consulta parecida a esta:
SELECT
post.id,
post.title,
post.author_id,
author.id,
author.username
FROM blog_post AS post
INNER JOIN auth_user AS author
ON post.author_id = author.id;
Las columnas de las dos tablas llegan en el mismo resultado SQL. Después, Django construye el Post y su autor a partir de cada fila. Acceder a post.author ya no lanza ninguna consulta.
El tipo de join no siempre es INNER JOIN. Si la relación acepta NULL, Django suele utilizar un join externo para conservar los artículos sin autor:
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
)
LEFT OUTER JOIN auth_user AS author
ON post.author_id = author.id
Los filtros del queryset también pueden influir en el join elegido. Por eso, conviene observar el SQL generado en lugar de memorizar una única forma.
Relaciones admitidas por select_related()
select_related() solo sigue relaciones que devuelven como máximo un objeto desde cada fila principal:
- una
ForeignKeyen sentido directo; - un
OneToOneFielden ambos sentidos; - varias relaciones unitarias encadenadas, como
post.author.profile.
posts = Post.objects.select_related("author__profile")
Django añade entonces un join por cada tabla recorrida. Sigue siendo una sola consulta, pero el resultado contiene más columnas. Añadir todas las relaciones sin comprobar si se utilizan aumenta innecesariamente la transferencia de red y el trabajo de la base de datos.
select_related() no puede cargar comments ni tags. Un join sobre una colección repetiría las columnas del Post por cada comentario o cada etiqueta, con el riesgo de multiplicar rápidamente las filas.
prefetch_related() ejecuta varias consultas
Para una relación inversa como Post.comments, se utiliza prefetch_related():
posts = Post.objects.prefetch_related("comments")
for post in posts:
for comment in post.comments.all():
print(comment.body)
Django ejecuta primero la consulta principal:
SELECT id, title, author_id
FROM blog_post;
Una vez conocidos los identificadores, lanza una segunda consulta:
SELECT id, post_id, body, is_public, author_id
FROM blog_comment
WHERE post_id IN (1, 2, 3, 4, 5);
La agrupación no se realiza con un JOIN SQL. Django construye una tabla de correspondencias en Python a partir de comment.post_id y después rellena la caché de post.comments para cada artículo.
El número de consultas permanece constante: dos consultas tanto para 5 artículos como para 500. Como contrapartida, todos los artículos y comentarios precargados se mantienen en memoria.
El caso de una relación ManyToMany
prefetch_related() también funciona con Post.tags. Normalmente, Django no necesita una tercera consulta para la tabla intermedia. La une en la consulta de las etiquetas:
posts = Post.objects.prefetch_related("tags")
-- Consulta 1: los artículos
SELECT id, title, author_id
FROM blog_post;
-- Consulta 2: las etiquetas y su artículo asociado
SELECT
post_tags.post_id AS _prefetch_related_val_post_id,
tag.id,
tag.name
FROM blog_tag AS tag
INNER JOIN blog_post_tags AS post_tags
ON tag.id = post_tags.tag_id
WHERE post_tags.post_id IN (1, 2, 3, 4, 5);
La columna adicional post_id permite a Django asociar cada etiqueta con los artículos correspondientes.
select_related() o prefetch_related(): la regla para elegir
Hazte una sola pregunta: desde cada objeto del queryset principal, ¿puede la relación devolver varios objetos?
| Relación consultada | Cardinalidad desde el objeto | Método recomendado | Consultas habituales |
|---|---|---|---|
post.author | 0 o 1 | select_related("author") | 1 |
post.profile en OneToOne | 0 o 1 | select_related("profile") | 1 |
post.comments | 0 a N | prefetch_related("comments") | 2 |
post.tags | 0 a N | prefetch_related("tags") | 2 |
| Colección filtrada | 0 a N | Prefetch() | 2 |
Técnicamente, prefetch_related() puede cargar una ForeignKey, pero en ese caso utiliza dos consultas donde select_related() puede realizar una sola. Puede seguir siendo apropiado si la tabla relacionada contiene muchas columnas o si el join genera un plan costoso, pero esta decisión debe basarse en una medición, no en una regla abstracta.
Si solo necesitas el identificador de la relación, ninguno de los dos métodos es útil:
for post in Post.objects.all():
print(post.author_id) # Ya está presente en la fila del Post
Combinar select_related() y prefetch_related()
Una página suele mostrar el autor de cada artículo y sus comentarios. En ese caso, los dos métodos son complementarios:
posts = (
Post.objects
.select_related("author")
.prefetch_related("comments")
)
Django ejecuta solo dos consultas:
-- Consulta 1: artículos y autores mediante un join
SELECT post.*, author.*
FROM blog_post AS post
INNER JOIN auth_user AS author
ON post.author_id = author.id;
-- Consulta 2: comentarios de todos los artículos
SELECT *
FROM blog_comment
WHERE post_id IN (1, 2, 3, 4, 5);
Sin optimización, esta página habría generado 1 + N + N consultas. Con 100 artículos, se pasa de 201 consultas a 2.
Evitar un N+1 en los objetos precargados
Precargar los comentarios no precarga automáticamente a sus autores:
posts = Post.objects.prefetch_related("comments")
for post in posts:
for comment in post.comments.all():
print(comment.author.username) # N+1 en los autores
Un recorrido anidado funciona:
posts = Post.objects.prefetch_related("comments__author")
Ejecuta tres consultas: artículos, comentarios y después autores. Como Comment.author es una relación unitaria, se puede mejorar uniendo los autores en la consulta de los comentarios:
from django.db.models import Prefetch
posts = Post.objects.prefetch_related(
Prefetch(
"comments",
queryset=Comment.objects.select_related("author"),
)
)
Esta versión vuelve a las dos consultas: una para los artículos y otra para los comentarios unidos a sus autores. Para profundizar en los querysets personalizados, los filtros y to_attr, consulta el artículo sobre Prefetch(), defer() y only().
La trampa de la caché de prefetch_related()
La caché precargada corresponde exactamente al queryset de la relación. Una llamada a .all() la reutiliza:
for post in posts:
list(post.comments.all()) # Ninguna consulta nueva
Sin embargo, una nueva operación sobre el manager construye otro queryset:
for post in posts:
post.comments.filter(is_public=True) # Nueva consulta
post.comments.order_by("-id") # Nueva consulta
Dentro de un bucle, se recrea así el N+1 que se creía eliminado. El filtro debe aplicarse durante el prefetch:
public_comments = Comment.objects.filter(is_public=True)
posts = Post.objects.prefetch_related(
Prefetch(
"comments",
queryset=public_comments,
to_attr="public_comments",
)
)
for post in posts:
for comment in post.public_comments:
print(comment.body)
Aquí, to_attr almacena una lista de Python explícita. Por tanto, post.comments.all() sigue significando todos los comentarios, mientras que post.public_comments designa únicamente el subconjunto precargado.
Ver el SQL real ejecutado por Django
str(queryset.query) muestra el SQL de la consulta principal:
posts = Post.objects.select_related("author")
print(posts.query)
Es suficiente para observar el JOIN de select_related(). No basta para prefetch_related(), porque la segunda consulta depende de los identificadores devueltos por la primera y solo se construye en el momento de la evaluación.
Para capturar todas las consultas, utiliza Django Debug Toolbar durante el desarrollo o CaptureQueriesContext en un test:
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
posts = Post.objects.prefetch_related("comments")
with CaptureQueriesContext(connection) as captured:
loaded_posts = list(posts)
for post in loaded_posts:
list(post.comments.all())
for query in captured:
print(query["sql"])
Después, un test de no regresión protege el número de consultas:
class PostQueryTests(TestCase):
def test_posts_and_comments_use_two_queries(self):
with self.assertNumQueries(2):
posts = list(Post.objects.prefetch_related("comments"))
comments = [list(post.comments.all()) for post in posts]
El contenido de comments parece no utilizarse, pero la asignación fuerza deliberadamente la evaluación de cada relación dentro del bloque medido.
Cuidado con los querysets muy grandes
prefetch_related() suele construir una cláusula IN que contiene los identificadores del queryset principal. Con unos cientos de filas, este comportamiento es normal. Con decenas de miles, la consulta resulta más pesada de analizar y todos los objetos cargados ocupan memoria.
La primera solución suele ser paginar. Para un procesamiento por lotes, iterator() puede precargar cada bloque por separado en las versiones recientes de Django:
posts = Post.objects.prefetch_related("comments")
for post in posts.iterator(chunk_size=500):
process(post)
El equilibrio cambia: la consulta principal sigue recorriéndose por bloques y Django ejecuta una consulta de prefetch por bloque. Se limitan la memoria y el tamaño de las cláusulas IN, pero aumenta el número total de consultas. Por tanto, hay que medir con un volumen representativo.
Lo que debes recordar
select_related() realiza un join SQL y es adecuado para las relaciones unitarias. prefetch_related() ejecuta consultas separadas y después asocia las colecciones en Python. No son métodos opuestos: un queryset realista suele combinar ambos.
La regla más fiable sigue siendo sencilla: parte de la cardinalidad, observa las consultas realmente ejecutadas y después asegura el resultado con assertNumQueries. Una optimización del ORM útil no se mide por el número de métodos encadenados, sino por el SQL y el volumen de objetos que genera.
