StackPractices
intermediate Por Mathias Paulenko

Patrón Eager Loading: resuelve el problema N+1

Carga datos relacionados en una sola consulta en lugar de múltiples llamadas a la base de datos, evitando el problema N+1 y mejorando el rendimiento de lectura.

Descripción General

El patrón Eager Loading carga los datos relacionados junto con la entidad principal en una sola consulta, en lugar de emitir una consulta distinta por cada relación. Así se evita el problema N+1: cargar N entidades y disparar después N consultas adicionales para sus datos relacionados, lo que suma N+1 llamadas a la base de datos.

La mayoría de los ORMs y capas de acceso a datos ofrecen carga anticipada mediante JOINs de SQL, consultas IN por lotes o subconsultas. El enfoque contrario, la carga diferida (lazy loading), pospone los datos relacionados hasta que el código los necesita de verdad: menos filas por adelantado a cambio de más consultas después.

Cuándo Usar

Usa el patrón Eager Loading cuando:

  • Sabes de antemano qué relaciones va a consumir el llamador, como un endpoint de listado que siempre muestra el propietario de cada elemento.
  • El problema N+1 es medible: los logs de consultas o un profiler muestran decenas de consultas pequeñas por petición.
  • Cada elemento de una lista renderizada necesita datos relacionados, así que cada fila obtenida dispararía al menos una consulta extra.
  • La latencia de red entre la aplicación y la base de datos es lo bastante alta como para que las idas y vueltas dominen el tiempo total.

Cuándo Evitar

  • Solo necesitas los datos relacionados para un subconjunto pequeño de entidades; la carga diferida los trae bajo demanda.
  • La relación contiene datos pesados (documentos, imágenes, blobs JSON) que la mayoría de peticiones nunca lee.
  • La memoria es limitada y materializar el grafo de objetos completo de una vez es prohibitivo.
  • El JOIN es lo bastante complejo como para degradar la base de datos más que varias consultas pequeñas bien indexadas.
  • Estás paginando una entidad padre sobre un JOIN uno-a-muchos; mira Carga anticipada y paginación más abajo.

Solución

Python

from dataclasses import dataclass
from typing import List, Optional
import sqlite3

@dataclass
class Author:
    id: int
    name: str

@dataclass
class Book:
    id: int
    title: str
    author_id: int
    author: Optional[Author] = None

class BookRepository:
    def __init__(self, conn):
        self._conn = conn

    # Problema N+1 (MALO)
    def find_all_with_authors_naive(self) -> List[Book]:
        books = []
        for row in self._conn.execute("SELECT id, title, author_id FROM books"):
            book = Book(id=row["id"], title=row["title"], author_id=row["author_id"])
            # ¡Una consulta adicional por libro!
            author_row = self._conn.execute(
                "SELECT id, name FROM authors WHERE id = ?", (book.author_id,)
            ).fetchone()
            if author_row:
                book.author = Author(id=author_row["id"], name=author_row["name"])
            books.append(book)
        return books

    # Eager Loading (BUENO): una sola consulta con JOIN
    def find_all_with_authors_eager(self) -> List[Book]:
        query = """
            SELECT b.id AS book_id, b.title, b.author_id,
                   a.id AS author_id, a.name AS author_name
            FROM books b
            JOIN authors a ON b.author_id = a.id
        """
        books = []
        for row in self._conn.execute(query):
            author = Author(id=row["author_id"], name=row["author_name"])
            book = Book(
                id=row["book_id"],
                title=row["title"],
                author_id=row["author_id"],
                author=author
            )
            books.append(book)
        return books

    # Carga por lotes: dos consultas, sin JOIN
    def find_all_with_authors_batch(self) -> List[Book]:
        books = []
        rows = self._conn.execute("SELECT id, title, author_id FROM books").fetchall()
        author_ids = [r["author_id"] for r in rows]

        # Una sola consulta para todos los autores
        placeholders = ",".join("?" * len(author_ids))
        authors = {
            r["id"]: Author(id=r["id"], name=r["name"])
            for r in self._conn.execute(
                f"SELECT id, name FROM authors WHERE id IN ({placeholders})", author_ids
            )
        }

        for row in rows:
            book = Book(
                id=row["id"],
                title=row["title"],
                author_id=row["author_id"],
                author=authors.get(row["author_id"])
            )
            books.append(book)
        return books


# Preparación
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT)")
conn.execute("CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT, author_id INTEGER)")
conn.execute("INSERT INTO authors (name) VALUES ('Alice'), ('Bob')")
conn.execute("INSERT INTO books (title, author_id) VALUES ('Book A', 1), ('Book B', 1), ('Book C', 2)")

repo = BookRepository(conn)
books = repo.find_all_with_authors_eager()
for book in books:
    print(f"{book.title} by {book.author.name}")

Java

Las clases de abajo omiten el modificador public para que todo el ejemplo compile en un solo archivo (javac EagerLoading.java). En un proyecto real cada clase viviría en su propio archivo.

import java.sql.*;
import java.util.*;

class Author {
    private final int id;
    private final String name;
    public Author(int id, String name) { this.id = id; this.name = name; }
    public int getId() { return id; }
    public String getName() { return name; }
}

class Book {
    private final int id;
    private final String title;
    private final int authorId;
    private Author author;

    public Book(int id, String title, int authorId) {
        this.id = id; this.title = title; this.authorId = authorId;
    }
    public int getId() { return id; }
    public String getTitle() { return title; }
    public Author getAuthor() { return author; }
    public void setAuthor(Author author) { this.author = author; }
}

class BookRepository {
    private final Connection conn;
    public BookRepository(Connection conn) { this.conn = conn; }

    public List<Book> findAllWithAuthors() throws SQLException {
        String query = """
            SELECT b.id AS book_id, b.title, b.author_id,
                   a.id AS author_id, a.name AS author_name
            FROM books b
            JOIN authors a ON b.author_id = a.id
            """;
        List<Book> books = new ArrayList<>();
        try (Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(query)) {
            while (rs.next()) {
                Author author = new Author(rs.getInt("author_id"), rs.getString("author_name"));
                Book book = new Book(rs.getInt("book_id"), rs.getString("title"), rs.getInt("author_id"));
                book.setAuthor(author);
                books.add(book);
            }
        }
        return books;
    }
}

// Uso
Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:");
conn.createStatement().execute("CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT)");
conn.createStatement().execute("CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT, author_id INTEGER)");
conn.createStatement().execute("INSERT INTO authors (name) VALUES ('Alice'), ('Bob')");
conn.createStatement().execute("INSERT INTO books (title, author_id) VALUES ('Book A', 1), ('Book B', 1), ('Book C', 2)");

BookRepository repo = new BookRepository(conn);
for (Book book : repo.findAllWithAuthors()) {
    System.out.println(book.getTitle() + " by " + book.getAuthor().getName());
}

JavaScript

El repositorio de abajo funciona sobre node:sqlite (incluido en Node ≥ 22.13), así que el bloque de uso se ejecuta de verdad en lugar de quedarse como pseudocódigo.

const { DatabaseSync } = require('node:sqlite');

class Author {
  constructor(id, name) {
    this.id = id;
    this.name = name;
  }
}

class Book {
  constructor(id, title, authorId) {
    this.id = id;
    this.title = title;
    this.authorId = authorId;
    this.author = null;
  }
}

class BookRepository {
  constructor(db) {
    this.db = db;
  }

  // Problema N+1 (MALO)
  findAllWithAuthorsNaive() {
    const books = this.db.prepare('SELECT id, title, author_id FROM books').all()
      .map(r => new Book(r.id, r.title, r.author_id));
    for (const book of books) {
      const author = this.db.prepare('SELECT id, name FROM authors WHERE id = ?')
        .get(book.authorId);
      book.author = author ? new Author(author.id, author.name) : null;
    }
    return books;
  }

  // Eager Loading (BUENO)
  findAllWithAuthorsEager() {
    const rows = this.db.prepare(`
      SELECT b.id AS book_id, b.title, b.author_id,
             a.id AS author_id, a.name AS author_name
      FROM books b
      JOIN authors a ON b.author_id = a.id
    `).all();
    return rows.map(r => {
      const book = new Book(r.book_id, r.title, r.author_id);
      book.author = new Author(r.author_id, r.author_name);
      return book;
    });
  }

  // Carga por lotes
  findAllWithAuthorsBatch() {
    const books = this.db.prepare('SELECT id, title, author_id FROM books').all()
      .map(r => new Book(r.id, r.title, r.author_id));
    const authorIds = [...new Set(books.map(b => b.authorId))];
    const authors = this.db.prepare(
      `SELECT id, name FROM authors WHERE id IN (${authorIds.join(',')})`
    ).all();
    const authorMap = new Map(authors.map(a => [a.id, new Author(a.id, a.name)]));

    for (const book of books) {
      book.author = authorMap.get(book.authorId) || null;
    }
    return books;
  }
}

// Uso
const db = new DatabaseSync(':memory:');
db.exec(`
  CREATE TABLE authors (id INTEGER PRIMARY KEY, name TEXT);
  CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT, author_id INTEGER);
  INSERT INTO authors (name) VALUES ('Alice'), ('Bob');
  INSERT INTO books (title, author_id) VALUES ('Book A', 1), ('Book B', 1), ('Book C', 2);
`);

const repo = new BookRepository(db);
for (const book of repo.findAllWithAuthorsEager()) {
  console.log(`${book.title} by ${book.author.name}`);
}

Explicación

Cargar 100 libros de la forma ingenua cuesta 101 consultas:

  1. SELECT id, title, author_id FROM books devuelve los 100 libros.
  2. SELECT id, name FROM authors WHERE id = ? se ejecuta una vez por libro.

La carga anticipada lo reduce a una sola ida y vuelta con un JOIN:

SELECT b.id, b.title, b.author_id, a.id AS author_id, a.name
FROM books b
JOIN authors a ON b.author_id = a.id

o a dos idas y vueltas con la carga por lotes: una consulta para los libros y otra con WHERE id IN (...) para todos los autores referenciados a la vez.

Problema N+1 frente a carga anticipada por lotes: la carga diferida emite una consulta de autor por libro, mientras que la carga por lotes obtiene todos los autores en una sola consulta IN.

Por qué los ORMs mezclan JOIN y lotes

El JOIN no es una victoria gratis. Unir un padre con una única relación a-uno (books -> authors) es seguro porque el número de filas no crece. Unir a través de una relación a-muchos repite la fila del padre una vez por hijo, y unir dos relaciones a-muchos las multiplica: 100 libros con 3 etiquetas cada uno ya producen 300 filas, y añadir un segundo JOIN a-muchos hacia reviews vuelve a multiplicar.

Por eso los ORMs maduros aplican estrategias distintas según la cardinalidad: JOIN para las relaciones a-uno (select_related, Include, joinedload) y una segunda consulta IN por lotes para las colecciones a-muchos (prefetch_related, AsSplitQuery, selectinload). Obtienes semántica de carga anticipada sin el producto cartesiano.

Carga Anticipada y Paginación

La paginación choca con los JOINs sobre relaciones a-muchos. LIMIT se aplica a las filas del resultado, y un books JOIN chapters repite la fila del libro una vez por capítulo, así que LIMIT 10 puede devolver apenas dos o tres libros.

Todos los ORMs importantes lo resuelven igual: ignoran el JOIN en la consulta de la página, paginan los padres y luego traen la colección con una consulta IN por lotes usando esos IDs de padre. Hibernate avisa exactamente de esto cuando combinas setMaxResults con una colección: “firstResult/maxResults specified with collection fetch; applying in memory”. El respaldo en memoria funciona, pero carga en silencio todo el JOIN sin paginar en la JVM primero.

Cómo Detectar el Problema N+1 en la Práctica

El N+1 rara vez aparece como una consulta lenta; aparece como número de consultas. Formas concretas de verlo:

  • Django: assertNumQueries(3) dentro de un test, o Django Debug Toolbar en desarrollo, muestra cada sentencia por petición.
  • Hibernate / JPA: hibernate.generate_statistics=true informa del número de consultas por sesión; una sesión que ejecuta cientos de selects es una señal de alerta.
  • SQLAlchemy: create_engine(url, echo=True) registra cada sentencia SQL durante el desarrollo.
  • Prisma / Node: suscríbete al evento query (prisma.$on('query', ...)) y registra e.duration por sentencia.
  • PostgreSQL en producción: pg_stat_statements muestra la misma consulta de autor ejecutada decenas de miles de veces por hora, algo que ningún índice arregla.

Variantes

VarianteMecanismoCaso de uso
JOINUna sola consulta SQL con JOINRelaciones a-uno, conjuntos de datos pequeños y medianos
Lotes / cláusula INDos consultas con una lista INColecciones a-muchos, padres paginados, evitar productos cartesianos
SubconsultaSubconsulta correlacionada para datos relacionadosBases de datos donde el optimizador maneja mejor subconsultas que JOINs anchos
GraphQL DataLoaderAgrupa y deduplica por peticiónAPIs con formas de consulta dinámicas; ver lotes con DataLoader

Lo que Funciona

  • Mide antes de optimizar. Aserciones sobre el número de consultas (assertNumQueries, estadísticas de Hibernate, un log con echo) para arreglar un N+1 probado y no uno supuesto.
  • JOIN para a-uno, lotes para a-muchos. Refleja lo que hacen los ORMs por dentro y evita los productos cartesianos que encarecen los JOINs sobre colecciones.
  • Selecciona solo las columnas que necesitas. SELECT * en un JOIN trae todas las columnas de ambas tablas; nombra los campos que el llamador realmente lee.
  • Elige el plan de carga por caso de uso. Una vista de detalle y una exportación masiva necesitan grafos de objetos distintos; casi todos los ORMs permiten elegir la estrategia por consulta en lugar de por entidad.
  • Vigila la memoria en grafos grandes. La carga anticipada de un grafo profundo puede materializar miles de objetos; carga por lotes y pagina en su lugar.

Errores Comunes

  • Cargar todo de forma anticipada por defecto. Marcar todas las relaciones como eager construye grafos de objetos enormes que nadie pidió.
  • Usar carga diferida dentro de un bucle. Iterar entidades y tocar book.author en cada elemento es exactamente cómo se produce el N+1.
  • Ignorar los productos cartesianos. Unir dos relaciones a-muchos multiplica las filas; el conjunto de resultados crece de forma cuadrática, no lineal.
  • Unir una colección a-muchos mientras se pagina. LIMIT cuenta filas unidas, no padres, así que las páginas vuelven incompletas o el ORM recurre a paginación en memoria.
  • Asumir que el JOIN siempre gana. Con conjuntos de datos grandes o tablas distantes, dos consultas indexadas pueden superar a un JOIN ancho; mide ambos.

Ejemplos del Mundo Real

Django ORM

Book.objects.select_related('author') emite un JOIN para relaciones a-uno, mientras que prefetch_related('tags') lanza una segunda consulta para relaciones a-muchos y cose los resultados en Python. Ver la documentación de querysets de Django.

SQLAlchemy

session.query(Book).options(joinedload(Book.author)) produce el JOIN, y selectinload(Book.tags) produce la consulta IN por lotes. La misma división JOIN-para-a-uno, lotes-para-a-muchos; ver la documentación de carga de relaciones de SQLAlchemy.

Entity Framework Core

.Include(b => b.Author) une las relaciones a-uno. Desde EF Core 5, .AsSplitQuery() divide los includes a-muchos en consultas separadas, evitando las filas de padre duplicadas que produciría un solo JOIN. Ver la documentación de carga anticipada de EF Core.

Rails Active Record

Book.includes(:author) elige entre preload (una consulta por lotes separada) y eager_load (un LEFT JOIN) según si la consulta filtra sobre la asociación. Ver la guía de consultas de Rails.

Prisma

prisma.book.findMany({ include: { author: true } }) resuelve las relaciones con consultas por lotes en lugar de un JOIN, razón por la que Prisma evita el N+1 sin exponer una API de estrategia de carga.

GraphQL DataLoader

DataLoader agrupa y deduplica cargas idénticas de un campo entre objetos padre en cada petición, resolviendo el N+1 en la capa de resolvers en lugar de la capa del ORM. La receta de lotes con DataLoader muestra una implementación funcional.

Véase También

Preguntas frecuentes

¿Qué es el problema N+1?

Cargar N entidades y emitir luego una consulta extra por entidad para los datos relacionados, lo que produce N+1 consultas donde bastarían una o dos. Aparece siempre que el código itera una colección y toca una relación de carga diferida dentro del bucle.

¿Cuándo debo elegir la carga por lotes en lugar de un JOIN?

Elige la carga por lotes para colecciones a-muchos, para padres paginados y siempre que un JOIN produjera un producto cartesiano. Elige un JOIN para relaciones a-uno y conjuntos de resultados pequeños donde una sola ida y vuelta es lo más barato.

¿Funciona la carga anticipada con paginación?

Solo con cuidado. Unir una colección a-muchos y aplicar LIMIT cuenta filas unidas, no padres, así que las páginas devuelven menos padres de los esperados. Los ORMs lo gestionan paginando primero los padres y cargando la colección por lotes después.

¿Es la carga anticipada siempre mejor que la diferida?

No. La carga anticipada gana cuando sabes que vas a usar los datos; la diferida gana cuando los datos son opcionales, grandes o solo necesarios en un subconjunto de filas. El valor por defecto equivocado en cualquiera de las dos direcciones desperdicia consultas o memoria.

¿Cómo detecto consultas N+1 en producción?

Observa el número de consultas, no su latencia. Activa el registro de consultas por petición (estadísticas de Hibernate, echo de SQLAlchemy, un listener de query en Prisma) o revisa pg_stat_statements buscando consultas idénticas ejecutadas miles de veces.