---
title: 'Eloquent y grandes volúmenes: chunk, lazy y cursor | DevSense'
description: 'Cómo procesar y exportar cientos de miles de filas en Laravel sin quedarse sin memoria: N+1 y preventLazyLoading, chunk frente a chunkById, lazy y cursor, buffering de PDO, streaming de CSV, Query Builder y SQL en bruto para informes.'
faq:
    - { question: '¿En qué se diferencia chunk() de cursor() en Laravel?', answer: 'chunk() ejecuta muchas consultas con LIMIT y OFFSET y pasa al closure una colección de N modelos, por lo que admite eager loading. cursor() ejecuta una sola consulta y crea los modelos de uno en uno mediante un generador, pero no admite eager loading, y el driver PDO, por defecto, mantiene igualmente en memoria todo el resultado en bruto de la consulta.' }
    - { question: '¿Por qué chunk() se salta registros si se actualizan dentro del bucle?', answer: 'chunk() pagina el resultado mediante OFFSET. Si el bucle modifica la columna por la que filtra la consulta (por ejemplo, processed = false → true), las filas procesadas salen de la selección y el siguiente OFFSET salta por encima de las que aún no se han procesado. chunkById() pagina por la clave primaria (WHERE id > último) y no tiene este problema.' }
    - { question: '¿Por qué cursor() sigue provocando falta de memoria?', answer: 'Los modelos se crean de uno en uno, pero PDO, por defecto, recibe del servidor todo el resultado de la consulta y lo guarda en un buffer del cliente: así funcionan tanto pdo_mysql (consultas con buffer) como pdo_pgsql. Con millones de filas, ese buffer no cabe en memory_limit. Para esos volúmenes, use lazyById() o un cursor de servidor de la base de datos.' }
    - { question: '¿Cuándo conviene renunciar a Eloquent en favor de Query Builder o de SQL en bruto?', answer: 'Cuando no se necesitan modelos: agregados e informes (GROUP BY, funciones de ventana), UPDATE masivos e INSERT ... SELECT, exportación de datos planos. Eloquent crea un objeto por cada fila y aplica casts y eventos: con cientos de miles de filas, eso supone segundos de más y cientos de megabytes. Además, en las operaciones masivas no se disparan los eventos de los modelos ni los observers.' }
published: '2026-10-03'
---
# Eloquent y grandes volúmenes de datos: N+1, chunk, lazy y cursor sin quedarse sin memoria

El informe «todos los pedidos del año en CSV» funciona en staging, donde hay cinco mil pedidos, y en producción falla con `Allowed memory size of 536870912 bytes exhausted`. El comando nocturno que recalcula los bonos procesa la mitad de los clientes y termina sin decir nada, y al día siguiente resulta que uno de cada dos clientes se ha quedado fuera. Una página con un listado de cincuenta artículos lanza doscientas consultas a la base de datos.

Las tres historias tratan de la misma habilidad: entender qué hace Eloquent con la base de datos y con la memoria, y elegir la herramienta adecuada según el volumen de datos. En este artículo: N+1, la diferencia entre `chunk`, `chunkById`, `lazy` y `cursor`, por qué `cursor` no le salva de quedarse sin memoria, cómo hacer streaming de una exportación de un millón de filas y cuándo es más honesto escribir SQL.

**Guías relacionadas:** [Optimización de consultas](database-query-optimization) · [Índices de bases de datos](database-indexes-deep-dive) · [PostgreSQL para dashboards](postgresql-for-dashboards) · [Colas de Laravel en producción](laravel-queues-production)

## Contenido

* [De dónde sale la falta de memoria](#why-memory)
* [N+1: cientos de consultas en lugar de dos](#n-plus-one)
* [Mapa de métodos para grandes selecciones](#methods)
* [chunk() y la trampa de OFFSET](#chunk)
* [lazy() y lazyById(): un flujo en lugar de lotes](#lazy)
* [cursor() y el buffering de PDO](#cursor)
* [Exportar a CSV un millón de filas](#export)
* [Cuándo hace falta Query Builder o SQL en bruto](#query-builder)
* [Cómo medir](#measure)
* [Errores frecuentes](#common-mistakes)
* [Checklist](#checklist)
* [Quiz de autoevaluación](#self-test-quiz)

---

<a id="why-memory"></a>
## De dónde sale la falta de memoria

`Order::where('year', 2026)->get()` hace tres cosas:

1. Ejecuta la consulta y trae **todas las filas** del resultado a la memoria de PHP.
2. Crea **un objeto de modelo por cada fila**: atributos, una copia de los valores originales para detectar cambios, casts, relaciones.
3. Mete los modelos en una **colección**, que vive mientras viva la variable.

Un modelo Eloquent ocupa en memoria varias veces más que la propia fila de la tabla. Diez mil modelos no suelen ser un problema; un millón, garantizan superar `memory_limit`. Por eso, con grandes volúmenes el objetivo es uno solo: **no tener nunca en memoria todo el resultado a la vez**, ni modelos ni filas en bruto.

---

<a id="n-plus-one"></a>
## N+1: cientos de consultas en lugar de dos

El clásico: un listado de artículos con los nombres de sus autores.

```php
$articles = Article::latest()->take(50)->get();

foreach ($articles as $article) {
    echo $article->author->name; // one extra query per article
}
```

Una consulta para los artículos más una consulta por el autor de cada artículo: 51 consultas. Añada etiquetas y el número de comentarios, y la página hace doscientos accesos a la base de datos. La solución es el **eager loading**:

```php
$articles = Article::latest()
    ->with(['author:id,name', 'tags'])
    ->withCount('comments')
    ->take(50)
    ->get();
```

Ahora hay cuatro consultas: artículos, autores, etiquetas (con la tabla intermedia) y el recuento de comentarios mediante una subconsulta. `author:id,name` carga solo las columnas necesarias; es obligatorio incluir la clave `id`.

Para que el N+1 no vuelva sin que nadie lo note, prohíba la carga lazy fuera de producción:

```php
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! $this->app->isProduction());
}
```

En desarrollo y en los tests, acceder a una relación no cargada lanzará una excepción, mientras que en producción el código seguirá funcionando. Las versiones recientes de Laravel ofrecen también el enfoque contrario, `Model::automaticallyEagerLoadRelationships()`: la primera vez que se accede a una relación, se carga de golpe para todos los modelos de la colección. Es una red de seguridad cómoda, pero un `with()` explícito sigue siendo más claro: el código muestra qué datos necesita la página.

---

<a id="methods"></a>
## Mapa de métodos para grandes selecciones

| Método | Consultas | En memoria a la vez | Eager loading | Seguro si cambia el filtro |
|-------|----------|-----------------------|---------------|---------------------------------|
| `get()` | 1 | Todos los modelos | Sí | — |
| `chunk(N)` | Muchas (`LIMIT/OFFSET`) | N modelos | Sí | **No** |
| `chunkById(N)` | Muchas (`WHERE id > ?`) | N modelos | Sí | Sí |
| `lazy(N)` | Muchas (`LIMIT/OFFSET`) | N modelos, entregados de uno en uno | Sí | **No** |
| `lazyById(N)` | Muchas (`WHERE id > ?`) | N modelos, entregados de uno en uno | Sí | Sí |
| `cursor()` | 1 | 1 modelo + todo el resultado en bruto en el buffer de PDO | **No** | Sí |

La regla por defecto para jobs en segundo plano y comandos: **`lazyById()`** o **`chunkById()`**. Limitan la memoria, admiten `with()` y no se saltan registros.

---

<a id="chunk"></a>
## chunk() y la trampa de OFFSET

`chunk()` divide la selección en páginas mediante `LIMIT` y `OFFSET`:

```php
Customer::where('bonus_recalculated', false)
    ->chunk(1000, function (Collection $customers) {
        foreach ($customers as $customer) {
            $customer->recalculateBonus(); // sets bonus_recalculated = true
        }
    });
```

Este código procesará aproximadamente la mitad de los clientes. El primer lote son las filas 1–1000; tras procesarlas, dejan de cumplir `bonus_recalculated = false`. La segunda consulta pide `OFFSET 1000`, pero la selección ya se ha desplazado mil filas y los clientes 1001–2000 se quedan fuera. No hay ningún error y el comando termina con éxito.

`chunkById()` pagina por la clave primaria: cada consulta siguiente es `WHERE id > :last_id ORDER BY id LIMIT 1000`. El desplazamiento de la selección no le afecta:

```php
Customer::where('bonus_recalculated', false)
    ->chunkById(1000, function (Collection $customers) {
        foreach ($customers as $customer) {
            $customer->recalculateBonus();
        }
    });
```

`OFFSET` tiene además un segundo problema: el rendimiento. Para devolver `OFFSET 900000 LIMIT 1000`, la base de datos igualmente lee y descarta 900 000 filas. Los últimos lotes tardan varias veces más que los primeros. La paginación por clave (keyset) usa el índice de la clave primaria y cuesta lo mismo a cualquier profundidad.

> [!WARNING]
> **Agrupe las condiciones con `orWhere`.** `chunkById()` y `lazyById()` añaden su propia condición `id > ?`. Si su consulta contiene `orWhere`, sin paréntesis se obtiene `a = 1 OR b = 2 AND id > 100`, y la paginación se rompe. Envuelva sus condiciones en un closure:
>
> ```php
> Customer::where(function ($query) {
>     $query->where('tier', 'gold')->orWhere('lifetime_cents', '>', 1_000_000);
> })->chunkById(1000, fn (Collection $customers) => /* ... */);
> ```

---

<a id="lazy"></a>
## lazy() y lazyById(): un flujo en lugar de lotes

Por dentro, `lazy()` hace lo mismo que `chunk()`, pero devuelve una `LazyCollection`: un flujo de modelos que se puede recorrer con un `foreach` normal y al que se pueden aplicar los métodos de colección:

```php
Customer::where('newsletter', true)
    ->with('subscription')
    ->lazyById(1000)
    ->filter(fn (Customer $customer) => $customer->subscription?->isActive())
    ->each(fn (Customer $customer) => SendDigest::dispatch($customer->id));
```

Los métodos de `LazyCollection` se ejecutan de forma lazy: `filter` y `each` procesan los modelos a medida que llegan, y en memoria solo está el lote actual de 1000. `with()` funciona: las relaciones se cargan para cada lote con una consulta aparte. Para recorrer en orden inverso existe `lazyByIdDesc()`.

El código con `lazyById()` se lee como un bucle normal, así que en los jobs nuevos resulta más cómodo que `chunkById()` con un closure.

---

<a id="cursor"></a>
## cursor() y el buffering de PDO

`cursor()` ejecuta **una sola** consulta y, mediante un generador, crea los modelos de uno en uno:

```php
foreach (Order::where('status', 'paid')->cursor() as $order) {
    // only one Order model is hydrated at a time
}
```

Parece ideal, pero tiene dos limitaciones.

**No hay eager loading.** En memoria solo hay un modelo, así que `with()` no se aplica. Acceder a `$order->customer` dentro del bucle vuelve a ser un N+1, y además sobre toda la selección.

**Todo el resultado en bruto sigue estando en memoria.** Por defecto, PDO recoge del servidor el resultado completo de la consulta y lo guarda en un buffer del cliente:

* **pdo_mysql** trabaja en modo de consultas con buffer (`PDO::MYSQL_ATTR_USE_BUFFERED_QUERY = true`);
* **pdo_pgsql** recibe de libpq todo el resultado de la consulta de una vez.

Los modelos se crean de uno en uno, pero el array de filas en bruto de un millón de registros está en la memoria del proceso, y tarde o temprano choca con `memory_limit`. La documentación de Laravel recomienda expresamente `lazy()` en lugar de `cursor()` para volúmenes muy grandes.

Si lo que necesita es precisamente una sola pasada sin paginación, hay dos formas honestas de hacer streaming.

**MySQL: consulta sin buffer en una conexión dedicada.**

```php
// config/database.php — a dedicated connection for exports
'mysql_unbuffered' => array_merge(config('database.connections.mysql'), [
    'options' => [PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false],
]),
```

Mientras no se haya leído el resultado completo, la conexión está ocupada: no se puede ejecutar otra consulta a través de ella. Por eso esa conexión se usa solo para leer el flujo, y las escrituras se hacen por la conexión principal.

**PostgreSQL: cursor de servidor.**

```php
DB::transaction(function () {
    DB::statement(
        'DECLARE export_cursor NO SCROLL CURSOR FOR
         SELECT id, customer_id, total_cents, created_at FROM orders WHERE created_at >= ?',
        [now()->startOfYear()],
    );

    while ($rows = DB::select('FETCH 5000 FROM export_cursor')) {
        foreach ($rows as $row) {
            // stream $row somewhere
        }
    }
});
```

El cursor vive en el servidor, y PHP recibe los datos en porciones de 5000 filas. Un cursor sin `WITH HOLD` solo existe dentro de una transacción. No conviene mantenerla abierta durante horas: una transacción larga impide que VACUUM limpie las versiones antiguas de las filas.

En la práctica, `lazyById()` cubre el 95 % de los casos, y el cursor de servidor hace falta cuando la ordenación no es por la clave primaria o cuando la consulta es compleja y resulta caro ejecutarla de nuevo para cada lote.

---

<a id="export"></a>
## Exportar a CSV un millón de filas

Una exportación típica sin quedarse sin memoria se basa en tres técnicas: seleccionar solo las columnas necesarias, no crear modelos y escribir el resultado en un flujo, no en una cadena.

```php
// app/Http/Controllers/OrderExportController.php
use Symfony\Component\HttpFoundation\StreamedResponse;

public function __invoke(Request $request): StreamedResponse
{
    $year = (int) $request->validate(['year' => ['required', 'integer', 'min:2020']])['year'];

    return response()->streamDownload(function () use ($year) {
        $out = fopen('php://output', 'w');
        fputcsv($out, ['id', 'customer_id', 'total', 'created_at']);

        DB::table('orders')
            ->select(['id', 'customer_id', 'total_cents', 'created_at'])
            ->whereYear('created_at', $year)
            ->lazyById(5000)
            ->each(function (object $row) use ($out) {
                fputcsv($out, [
                    $row->id,
                    $row->customer_id,
                    number_format($row->total_cents / 100, 2, '.', ''),
                    $row->created_at,
                ]);
            });

        fclose($out);
    }, "orders-{$year}.csv", ['Content-Type' => 'text/csv']);
}
```

* `DB::table()` en lugar del modelo: a la salida hay objetos `stdClass` ligeros, sin casts ni detección de cambios. Para una consulta construida sobre un modelo, `->toBase()` consigue lo mismo.
* `select()` limita las columnas: `SELECT *` arrastra campos `TEXT` que en el CSV no hacen falta.
* `streamDownload()` envía los datos al cliente a medida que se generan; la respuesta no se monta en memoria.

> [!NOTE]
> **Una exportación de más de 30 segundos va a la cola.** La petición HTTP chocará con los timeouts de PHP-FPM, nginx o el balanceador. Una exportación grande es mejor generarla en un job de cola, en un archivo en disco o en S3, y enviar al usuario un enlace. Los detalles sobre timeouts y memoria de los workers están en el artículo sobre [colas en producción](laravel-queues-production#heavy-jobs).

Fíjese en `whereYear()`: una función sobre la columna impide usar el índice de `created_at`. Para tablas grandes es más fiable un rango, `whereBetween('created_at', [$from, $to])` (más detalles en el artículo sobre [índices](database-indexes-deep-dive)).

---

<a id="query-builder"></a>
## Cuándo hace falta Query Builder o SQL en bruto

Eloquent es cómodo cuando se necesitan modelos: lógica de negocio, relaciones, eventos. Para trabajar con los datos «en bloque», a menudo sobra.

**Calcule los agregados en la base de datos, no en PHP.**

```php
// Bad: loads every order into PHP to sum one column
$total = Order::where('status', 'paid')->get()->sum('total_cents');

// Good: the database returns one number
$total = Order::where('status', 'paid')->sum('total_cents');

// Reports: grouping and window functions belong in SQL
$daily = DB::table('orders')
    ->selectRaw('date(created_at) as day, count(*) as orders, sum(total_cents) as revenue')
    ->where('created_at', '>=', now()->subDays(30))
    ->groupByRaw('date(created_at)')
    ->orderBy('day')
    ->get();
```

**Cambios masivos, con una sola consulta.**

```php
// One UPDATE instead of loading and saving 200 000 models
Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDays(30))
    ->update(['status' => 'expired']);

// Batch upsert for imports: one statement per batch of rows
DB::table('product_prices')->upsert(
    $rows,                       // array of ['sku' => ..., 'price_cents' => ..., 'updated_at' => ...]
    uniqueBy: ['sku'],
    update: ['price_cents', 'updated_at'],
);
```

Un `update()` masivo a través del builder de Eloquent rellena `updated_at`, pero **no dispara los eventos de los modelos ni los observers**, no aplica los mutators y no comprueba `$fillable`. Si hay lógica colgada del evento `updated` (limpieza de caché, auditoría), hay que ejecutarla explícitamente.

**Consultas complejas, con SQL honesto y parámetros enlazados.** `INSERT ... SELECT`, las CTE y las funciones de ventana mediante `DB::select()` o `selectRaw()` se leen mejor que una cadena de veinte métodos del builder. La regla principal: los valores, solo mediante placeholders:

```php
// Safe: values are bound, never concatenated into SQL
$rows = DB::select(
    'SELECT customer_id, sum(total_cents) AS spent
     FROM orders WHERE created_at >= ? GROUP BY customer_id HAVING sum(total_cents) > ?',
    [$from, 100_000],
);
```

Insertar la entrada del usuario en la cadena SQL es el camino directo a una inyección SQL, incluso en un informe «interno» (más detalles en el artículo sobre [ataques web](web-attacks-and-prevention)).

---

<a id="measure"></a>
## Cómo medir

No adivine: mida con un volumen de datos realista:

```php
$start = hrtime(true);
DB::enableQueryLog();

// ... code under test ...

logger()->info('export stats', [
    'queries' => count(DB::getQueryLog()),
    'peak_mb' => round(memory_get_peak_usage(true) / 1024 / 1024, 1),
    'ms' => (int) ((hrtime(true) - $start) / 1_000_000),
]);
```

* `memory_get_peak_usage(true)` muestra el pico, no el valor actual, y es precisamente el pico lo que choca con `memory_limit`.
* Active el log de consultas solo durante la medición. En comandos largos, el propio log se convierte en una fuga: cada consulta se guarda en un array. Por el mismo motivo, Telescope y Debugbar, que recopilan consultas, inflan la memoria de los workers de larga duración.
* Para las consultas que siguen siendo lentas después de corregir el N+1, revise el plan de ejecución con `EXPLAIN ANALYZE` (en detalle en la [clase magistral de optimización de consultas](database-query-optimization)).

---

<a id="common-mistakes"></a>
## Errores frecuentes

**1. `chunk()` modificando una columna de la condición.**
La mitad de los registros se salta sin avisar. Use `chunkById()` o `lazyById()`.

**2. `cursor()` como remedio contra la falta de memoria.**
Los modelos se crean de uno en uno, pero PDO mete en buffer el resultado en bruto. Con millones de filas, el mismo OOM, solo que más tarde.

**3. Acceder a relaciones dentro de `cursor()`.**
El eager loading no funciona, y se obtiene un N+1 sobre toda la selección.

**4. `get()->sum()`, `get()->count()`, `all()->filter()`.**
Los agregados y filtros calculados en PHP traen a memoria toda la tabla. Calcule en la base de datos.

**5. `orWhere` sin agrupar junto con `chunkById()`.**
La condición `id > ?` se pega a su `OR`, y la paginación se rompe.

**6. Un `update()` masivo donde se necesitan los eventos del modelo.**
Los observers y los eventos no se disparan: la caché no se invalida y la auditoría no se escribe.

**7. Log de consultas activado en un comando largo.**
Cada consulta se acumula en memoria. Active el log solo para medir.

---

<a id="checklist"></a>
## Checklist

1. Los listados con relaciones se cargan con `with()` y `withCount()`; `preventLazyLoading()` está activado fuera de producción.
2. Las selecciones de más de unos pocos miles de filas se procesan con `lazyById()` o `chunkById()`, no con `get()`.
3. No se usan `chunk()` ni `lazy()` si el bucle modifica columnas de la condición de la consulta.
4. `cursor()` se usa de forma consciente: sin relaciones y entendiendo el buffering de PDO.
5. Las exportaciones seleccionan solo las columnas necesarias, funcionan sin modelos y escriben en un flujo.
6. Las exportaciones e importaciones largas se ejecutan en la cola, no en la petición HTTP.
7. Los agregados y los cambios masivos se ejecutan en la base de datos con una sola consulta.
8. El SQL en bruto usa únicamente parámetros enlazados.
9. El pico de memoria y el número de consultas se han comprobado con un volumen de datos realista.

---

## Resumen

Eloquent no es lento: hace exactamente lo que se le pide, carga todo lo que ha devuelto la consulta y crea un objeto por cada fila. Los grandes volúmenes exigen otra pregunta: «¿cuánto de esto estará en memoria a la vez?». Para procesar, `lazyById()`; para informes, agregados en la base de datos; para exportar, un flujo sin modelos; para cambios masivos, una sola consulta. Y que `chunk()` con una condición que cambia y `cursor()` sobre millones de filas se queden en la lista de errores frecuentes, y no en su producción.

---

<a id="self-test-quiz"></a>
## Cuestionario de autoevaluación

### Pregunta 1: Un comando actualiza `processed = true` en los registros seleccionados con la condición `processed = false` mediante `chunk(500)`. ¿Qué ocurrirá?
- A) Se procesarán todos los registros, pero más despacio que con `chunkById()`.
- B) Aproximadamente la mitad de los registros se saltará: la selección se desplaza mientras el `OFFSET` crece.
- C) Laravel lanzará una excepción por modificación concurrente de los datos.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
Tras procesar el primer lote, esas filas dejan de cumplir la condición, y la página siguiente con `OFFSET 500` salta por encima de registros aún no procesados. `chunkById()` pagina por `id > último` y no depende del desplazamiento de la selección.
</details>

### Pregunta 2: ¿Por qué `cursor()` puede agotar la memoria con una selección de cinco millones de filas, aunque los modelos se creen de uno en uno?
- A) Los generadores de PHP guardan todos los valores entregados anteriormente.
- B) El driver PDO, por defecto, recibe todo el resultado de la consulta y lo guarda en un buffer del cliente.
- C) `cursor()` carga automáticamente todas las relaciones de los modelos.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
Tanto pdo_mysql en modo con buffer como pdo_pgsql recogen el resultado de la consulta completo. Para volúmenes muy grandes se usa `lazyById()`, una conexión MySQL sin buffer o un cursor de servidor de PostgreSQL.
</details>

### Pregunta 3: Hay que pasar 300 000 pedidos vencidos al estado `expired`. Del evento `updated` del modelo `Order` cuelga una invalidación de caché. ¿Qué enfoque es correcto?
- A) Una sola consulta `Order::where(...)->update(['status' => 'expired'])`: los eventos se dispararán automáticamente.
- B) Un único `update()` masivo y una invalidación explícita de la caché después, o bien `lazyById()` guardando los modelos si cada registro necesita la lógica del evento.
- C) `Order::where(...)->get()->each->update(...)`: es la opción más rápida.

<details>
<summary><b>Mostrar respuesta</b></summary>

**Respuesta: B**
Un `update()` masivo no dispara los eventos de los modelos ni los observers. Si se necesita su lógica, se ejecuta explícitamente después de la consulta o se procesan los modelos como flujo con `lazyById()`. La opción C carga en memoria los 300 000 modelos.
</details>