Por defecto, Django carga todos los campos de un modelo en cada consulta. En una vista de lista con 50 artículos, eso significa obtener el contenido completo, el resumen, los metadatos y los campos de traducción 50 veces, aunque solo se muestre el título y la fecha. Cuatro métodos QuerySet de Django permiten controlar exactamente qué se carga: defer(), only(), values_list() y Prefetch(). El resultado: 2 consultas SQL en lugar de N+2, con únicamente las columnas necesarias.

Django defer(): excluir los campos pesados del QuerySet

Django defer() indica al ORM que excluya ciertos campos de la consulta inicial. Los campos excluidos siguen siendo accesibles en la instancia, pero cada acceso lanza una consulta adicional.

class Post(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField()
    content = models.TextField()     # campo pesado
    excerpt = models.TextField()     # campo pesado
    is_published = models.BooleanField(default=False)
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    published_at = models.DateTimeField()
# Vista de lista: el contenido no se muestra
posts = Post.objects.filter(is_published=True).defer("content", "excerpt")

SQL generado:

SELECT id, title, slug, is_published, author_id, published_at
FROM blog_post
WHERE is_published = true;

La trampa clásica: acceder a un campo diferido dentro de un bucle.

# ❌ N+1 silencioso: 1 consulta extra por post
for post in Post.objects.defer("content"):
    print(post.content)  # lanza SELECT content FROM blog_post WHERE id = ?

# ✅ Si el contenido es necesario, no usar defer()
for post in Post.objects.all():
    print(post.content)

defer() es útil cuando se controlan estrictamente los campos renderizados. En un serializador que accede a todo, resulta contraproducente. Ver también in_bulk() para limitar las consultas N+1 en operaciones masivas.

Las llamadas a defer() se acumulan:

Post.objects.defer("content").defer("excerpt")
# → excluye content Y excerpt (equivalente a defer("content", "excerpt"))

Django only(): cargar únicamente los campos necesarios

only() es lo contrario de defer(): se especifican los campos a cargar y todo lo demás queda diferido. Es más explícito cuando la lista de campos necesarios es corta.

# Cargar solo lo necesario para la vista de lista
posts = Post.objects.only("id", "title", "slug", "author_id", "published_at")

SQL generado:

SELECT id, title, slug, author_id, published_at
FROM blog_post;

A diferencia de defer(), las llamadas a only() se reemplazan en lugar de acumularse:

Post.objects.only("title").only("slug", "author_id")
# → carga solo slug y author_id (title queda descartado)

Combinar only() con select_related() es habitual y funciona correctamente:

posts = Post.objects.only("id", "title", "author_id").select_related("author")
# 1 consulta con JOIN, campos mínimos, autor en caché

values_list(): cuando no se necesitan instancias del modelo

values_list() devuelve tuplas en lugar de instancias del modelo. No se instancia ningún objeto Python, lo que elimina el overhead del mapeo ORM.

# Tuplas (id, title)
Post.objects.filter(is_published=True).values_list("id", "title")
# → <QuerySet [(1, "Primer artículo"), (2, "Segundo artículo"), ...]>

# Valores escalares con flat=True (un solo campo obligatorio)
Post.objects.filter(is_published=True).values_list("id", flat=True)
# → <QuerySet [1, 2, 3, ...]>

# Named tuples para acceso por atributo
result = Post.objects.values_list("id", "title", named=True).first()
# result.title  ✅

flat=True solo funciona con un único campo. Con varios campos, Django lanza un TypeError.

Casos de uso típicos: exportación CSV, recopilación de IDs para operaciones bulk, alimentación de una caché Redis. Cuando no se necesitan los métodos del modelo, values_list() es la mejor opción. Funciona bien junto con F() en values() para renombrar campos ORM.

Las relaciones se pueden recorrer directamente con la notación de doble guion bajo. Ni select_related() ni prefetch_related() son necesarios: Django genera el JOIN automáticamente, y ambos son ignorados silenciosamente con values() / values_list().

# ✅ JOIN directo, sin necesidad de select_related()
Post.objects.values_list("id", "author__username")
# → [(1, "alice"), (2, "alice"), ...]

# Ambos son superfluos aquí: Django une la tabla igualmente
Post.objects.select_related("author").values_list("id", "author__username")
Post.objects.prefetch_related("author").values_list("id", "author__username")
# Django hace el JOIN en ambos casos; el prefetch nunca se ejecuta

Cuidado con las relaciones ManyToMany o FK inversas: el JOIN automático multiplica las filas. Si un post tiene 3 tags, aparece 3 veces en el resultado.

# Post tiene una M2M con Tag
Post.objects.values_list("title", "tags__name")
# → [("Artículo A", "python"), ("Artículo A", "django"), ("Artículo A", "orm")]
#    ↑ Artículo A aparece una vez por tag

# ✅ Deduplicar con distinct()
Post.objects.values_list("title", "tags__name").distinct()

Django Prefetch(): filtrar relaciones con un queryset personalizado

prefetch_related() solo carga todas las relaciones sin ningún filtro. El objeto Prefetch de Django permite pasar un queryset personalizado para filtrar, ordenar o anotar los objetos prefetcheados sin afectar a otras relaciones.

from django.db.models import Prefetch

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    body = models.TextField()
    is_published = models.BooleanField(default=False)
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

Sin Prefetch, se cargan todos los comentarios, publicados o no:

posts = Post.objects.prefetch_related("comments")

Con Prefetch, el filtro se integra directamente en la consulta de prefetch:

published_comments = Comment.objects.filter(is_published=True).order_by("-created_at")

posts = Post.objects.prefetch_related(
    Prefetch("comments", queryset=published_comments)
)

SQL generado (Django añade automáticamente el filtro por IDs de posts):

-- Consulta 1: los posts
SELECT * FROM blog_post;

-- Consulta 2: solo comentarios publicados, vinculados a los posts obtenidos
SELECT * FROM blog_comment
WHERE is_published = true
  AND post_id IN (1, 2, 3, ...)
ORDER BY created_at DESC;

Dentro del bucle, se usa el caché de prefetch:

for post in posts:
    print(post.comments.all())        # ✅ caché de prefetch usado
    print(post.comments.filter(...))  # ❌ consulta extra (caché ignorado)

Llamar a .filter() después del prefetch invalida el caché. Si se necesita un subconjunto, el filtro debe estar en el queryset del Prefetch. Es el mismo principio que select_for_update() en transacciones concurrentes: la intención debe declararse a nivel de consulta.

to_attr: almacenar el resultado en un atributo dedicado

to_attr almacena el resultado del prefetch en un atributo Python en lugar de alimentar el manager de la relación. Es necesario cuando se quieren varios prefetches sobre la misma relación, ya que dos objetos Prefetch apuntando al mismo lookup sin to_attr lanzan un ValueError.

from django.utils import timezone
from datetime import timedelta

posts = Post.objects.prefetch_related(
    Prefetch(
        "comments",
        queryset=Comment.objects.filter(is_published=True),
        to_attr="published_comments",
    ),
    Prefetch(
        "comments",
        queryset=Comment.objects.filter(
            created_at__gte=timezone.now() - timedelta(days=7)
        ),
        to_attr="recent_comments",
    ),
)

for post in posts:
    print(post.published_comments)  # ✅ lista Python
    print(post.recent_comments)     # ✅ lista Python
    print(post.comments.all())      # ⚠️ consulta extra (to_attr no alimenta el manager)

También se puede anidar select_related() dentro del queryset de un Prefetch para evitar N+1 en relaciones anidadas:

posts = Post.objects.prefetch_related(
    Prefetch(
        "comments",
        queryset=Comment.objects.select_related("author").filter(is_published=True),
    )
)

for post in posts:
    for comment in post.comments.all():
        print(comment.author.username)  # ✅ ninguna consulta extra

¿Qué método Django ORM elegir?

MétodoDevuelveUsar cuando
defer(*fields)Instancias del modeloExcluir pocos campos pesados bien identificados
only(*fields)Instancias del modeloLa lista de campos necesarios es corta
values_list()Tuplas o escalaresNo se necesitan los métodos del modelo
Prefetch()Instancias del modeloFiltrar o anotar relaciones prefetcheadas

Los cuatro se pueden combinar. Un queryset típico en una vista de lista:

published_comments = Comment.objects.filter(is_published=True).select_related("author")

posts = (
    Post.objects.filter(is_published=True)
    .only("id", "title", "slug", "author_id", "published_at")
    .select_related("author")
    .prefetch_related(Prefetch("comments", queryset=published_comments))
    .order_by("-published_at")
)

Resultado: 2 consultas SQL en lugar de N+2, con únicamente las columnas necesarias.

Medir antes de optimizar con Django Debug Toolbar

Ninguno de estos métodos es útil sin medición. connection.queries y Django Debug Toolbar son las herramientas de referencia:

from django.db import connection

posts = list(Post.objects.defer("content"))
print(len(connection.queries))  # Número de consultas ejecutadas

str(queryset.query) muestra el SQL generado antes de la evaluación. Útil para verificar que defer() o only() excluye realmente las columnas esperadas, y que Prefetch() aplica el filtro directamente en la base de datos.