# Laravel chunk vs lazy vs cursor: lazy() Is as Slow as chunk()

**Author:** Mozex | **Published:** 2026-09-23 | **Tags:** Laravel, PHP, Performance, Database | **URL:** https://mozex.dev/blog/26-laravel-chunk-vs-lazy-vs-cursor-lazy-is-as-slow-as-chunk

---


I seeded a MySQL table with 1,000,000 orders and walked through every row with each of five Laravel methods for iterating a big query: `chunk()`, `chunkById()`, `lazy()`, `lazyById()` and `cursor()`. `chunkById()` finished in 21.9 seconds and `lazyById()` in 22.3, neither above 5.9 MB of PHP memory. `chunk()` and `lazy()` held just as little and took about six times as long. `cursor()`, which [the Laravel docs](https://laravel.com/docs/eloquent#cursors) offer as a way to "significantly reduce your application's memory consumption", finished behind both ById methods and held 100.4 MB.

<!--more-->

For a big table, use `lazyById()`, or `chunkById()` when you want the rows in batches, and group any `orWhere()` in a closure first. The rest is the numbers and the lines of framework source that explain them. Everything below ran on Laravel 13.33.0, PHP 8.5.10, MySQL 8.4.11 and PostgreSQL 18.6 in September 2026.

## The ById pair was fastest and flat on memory

Each method walked all 1,000,000 rows through the `Order` model, one `php artisan` process per run, three rounds with the methods interleaved. The table shows the median:

| Method | Time | Peak PHP memory | Queries |
|---|---|---|---|
| `get()`, for scale | 25.8 s | 1,273.1 MB | 1 |
| `chunk(1000)` | 135.0 s | 4.6 MB | 1,001 |
| `chunkById(1000)` | 21.9 s | 4.6 MB | 1,001 |
| `lazy(1000)` | 133.7 s | 5.8 MB | 1,001 |
| `lazyById(1000)` | 22.3 s | 5.9 MB | 1,001 |
| `cursor()` | 63.8 s | 100.4 MB | 1 |
| `cursor()`, buffering off | 62.0 s | 3.3 MB | 1 |

Time is `hrtime()` around the loop, which reads `total_cents` from every model. Memory is `memory_get_peak_usage()` after a `memory_reset_peak_usage()` just before the loop, so it includes the 3.2 MB the booted app already used. Queries come from a `DB::listen()` counter. The rows have eight short columns. PHP ran on Windows 11 under Herd, MySQL in Docker with a 1 GB buffer pool, so the seconds are this machine's: compare the rows with each other, not with your server.

## `lazy()` pages the same way as `chunk()`

Four of the five methods are two strategies with two interfaces each. `chunk()` and `lazy()` ask for page N with OFFSET. `chunkById()` and `lazyById()` remember the last id and ask for the rows after it. `lazy()` hands you a `LazyCollection` instead of calling a closure, but in [BuildsQueries.php](https://github.com/laravel/framework/blob/v13.33.0/src/Illuminate/Database/Concerns/BuildsQueries.php) both it and `chunk()` fetch every page with `$this->offset($offset)->limit($limit)->get()`.

Here are the last full pages each strategy asked for, from the query log:

```sql
select * from `orders` order by `orders`.`id` asc limit 1000 offset 999000
select * from `orders` where `id` > 999000 order by `id` asc limit 1000
```

`EXPLAIN ANALYZE` on the first one shows MySQL reading 1,000,000 rows through the primary key to return 1,000. The second reads 1,000. Across the run, `chunk()`'s queries took 116.0 of its 135.0 seconds and `chunkById()`'s took 3.4.

I'm spelling this out because three of the pages I read for this post describe `lazy()` otherwise. [Tech Verse Daily](https://techversedaily.com/post/laravel-chunk-vs-cursor-fixing-the-mutation-bug) (July 2026) says it "uses chunkById()" under the hood, [Rivercrane](https://rivercrane.vn/en/blog/technical/hieu-ro-get-chunk-lazy-cursor-trong-laravel-15920/) (May 2025) that it "internally behaves like cursor()", and [Gold Lapel](https://goldlapel.com/grounds/laravel-php/laravel-chunk-cursor-lazy-postgres) (March 2026) that it runs the "Same single query" as `cursor()`.

## `chunk()` and `lazy()` skip rows you update

Here's an update inside `chunk()` that filters on the column it changes:

```php
Order::where('status', 'pending')
    ->chunk(1000, fn ($orders) => $orders->each->update(['status' => 'processed']));
```

On a 10,000-row copy with every row pending, it left 5,000 rows pending. The same update through `lazy(1000)` also left 5,000. `chunkById()` and `lazyById()` processed all 10,000. Every update moves a row out of the filter, so OFFSET 1000 on the second page steps over a thousand rows that are still pending. The [Laravel docs](https://laravel.com/docs/eloquent#chunking-results) say to use `chunkById()` here, and `lazyById()` in place of `lazy()`.

## `cursor()` keeps the whole result set in memory

`cursor()` runs one query and hydrates one model at a time, which is where its reputation comes from. pdo_mysql runs buffered queries by default: the whole result is copied into the PHP process when the query executes, and with mysqlnd "the memory accounted for will include the full result set" ([PHP manual](https://www.php.net/manual/en/mysqlinfo.concepts.buffering.php)). That's the 100.4 MB in the table. The Laravel docs do warn about it, at the end of the cursor section.

You can turn buffering off for one connection. Copy your `mysql` connection in `config/database.php` under a new name, `mysql_unbuffered` here, and replace its `options` entry with this:

```php
'options' => [
    \Pdo\Mysql::ATTR_USE_BUFFERED_QUERY => false,
],
```

If you set `MYSQL_ATTR_SSL_CA`, add its entry as a second line here, not inside the original `array_filter()`, which drops the `false`. `Order::on('mysql_unbuffered')->cursor()` then peaked at 3.3 MB. The PHP manual adds that until every row is read, "no further queries can be sent over the same connection." Loading a relation inside the loop fails, and so does `$order->update()`, because the model belongs to that connection:

```text
Illuminate\Database\QueryException: SQLSTATE[HY000]: General error: 2014 Cannot execute queries while other unbuffered queries are active.  Consider using PDOStatement::fetchAll().  Alternatively, if your code is only ever going to run against mysql, you may enable query buffering by setting the Pdo\Mysql::ATTR_USE_BUFFERED_QUERY attribute. (Connection: mysql_unbuffered, Host: 127.0.0.1, Port: 13384, Database: bench, SQL: select * from `users` where `users`.`id` = 7543 limit 1)
```

Writing through the default connection works: `Order::whereKey($order->id)->update([...])` inside the same loop updated all 10,000 rows of the copy. And `cursor()` can't eager load anyway, as the docs warn. `with('user')` is ignored, and touching `$order->user` on the first 10,000 rows of the table ran 10,001 queries. `lazyById()` with that `with()`, stopped after those 10,000 rows, ran 20.

## On PostgreSQL, PHP's memory counters can't see the buffer

pdo_pgsql buffers the result too, but the buffer belongs to libpq, the C client library, and its size is "not reflected in the `memory_get_usage()` output" ([php-src #14260](https://github.com/php/php-src/pull/14260)). In five runs, the same `cursor()` loop against PostgreSQL 18 reported a peak of 3.3 MB from `memory_get_peak_usage()`, while the process's peak working set (Windows' name for resident memory) went from 87.7 MB before the loop to 324.8 MB. Run with `memory_limit=128M`, the loop finished without an error. Anything that reads PHP's own counters misses the buffer, including the `--memory` flag of `queue:work` that I recommend in [my queue post](https://mozex.dev/blog/12-5-laravel-queue-failures-that-only-show-up-in-production#2-workers-silently-eating-all-your-memory): the worker checks `memory_get_usage(true)`.

That function stayed at 4 MB at rows 100,000, 200,000 and 900,000 of the loop. [Gold Lapel](https://goldlapel.com/grounds/laravel-php/laravel-chunk-cursor-lazy-postgres) shows it reporting 218.5 MB for a loop like this, with no PHP version named. The same check on Linux (PHP 8.5.10 in Docker) matched: `memory_get_usage(true)` stayed flat at 22 MB while resident memory went from 43.4 MB to 280.9 MB.

[PHP 8.5's upgrade notes](https://github.com/php/php-src/blob/php-8.5.10/UPGRADING) say that setting `PDO::ATTR_PREFETCH` to 0 on a PostgreSQL connection "enters lazy fetch mode":

```php
'options' => [
    PDO::ATTR_PREFETCH => 0,
],
```

With that on its own connection, set up like the MySQL one, the PostgreSQL loop peaked at 88.9 MB of working set in five runs. The same note adds: "In this mode, statements cannot be run in parallel."

In practice that's worse than MySQL's error. I loaded `$order->user` on the first row of the loop, and the loop ended there: 1 row of 1,000,000, no exception. In PHP 8.5.10, [pdo_pgsql](https://github.com/php/php-src/blob/php-8.5.10/ext/pdo_pgsql/pgsql_statement.c) lets the new query take over the connection and throws away the rest of the running result; a `@todo` comment in that file says so. Keep the option off your main `pgsql` connection, because every `cursor()` on a connection with it behaves this way.

## Where `cursor()` loses the time

One query should beat a thousand. Timing each layer over the same million rows, in a separate set of runs (median of three), shows where it loses: a raw PDO loop took 2.1 s, the connection's `cursor()` 2.3 s, the query builder's `cursor()` 3.6 s, and `toBase()->cursor()` on the Eloquent query 3.7 s. Adding one `newFromBuilder()` per row to the connection's `cursor()` took it to 26.8 s. Eloquent's own `cursor()` took 61.6 s. Even that `newFromBuilder()` loop was slower than the whole `chunkById()` run, which took 22.7 s in that set; I didn't trace that part.

The step from 26.8 to 61.6 s is what Eloquent's `cursor()` adds on top. [Its source](https://github.com/laravel/framework/blob/v13.33.0/src/Illuminate/Database/Eloquent/Builder.php#L1081) builds three models for every row: `newModelInstance()` creates one, `newFromBuilder()` on it creates the one you get, and a second `newModelInstance()` exists only to wrap that model in a collection for `afterQuery` callbacks. I counted constructor calls over the first 10,000 rows: 30,001 for `cursor()`, 10,011 for `chunkById()`, `lazy()` and `lazyById()`. The wrapper arrived with the `afterQuery` hook in Laravel 11 ([#50587](https://github.com/laravel/framework/pull/50587), April 2024). If an export doesn't need models, `toBase()` skips all of it.

## `chunkById()` and `lazyById()` have two traps

`chunkById()` and `lazyById()` accept `column:`, but the column has to be unique: on the 10,000-row copy, where every row has the same `created_at`, `chunkById(1000, ..., column: 'created_at')` processed 1,000 rows and stopped, and `lazyById()` returned 1,000 too. It also needs an index, for the reason in [the ORDER BY section of my indexing post](https://mozex.dev/blog/14-a-practical-guide-to-database-indexing-in-laravel#columns-in-order-by).

Both methods also append `where id > ?` to your conditions, and a root-level `orWhere` swallows it. Here's the second page from my run on the copy (its table is `repro_orders`), with half the rows paid and half refunded:

```sql
select * from `repro_orders` where `status` = 'paid' or `status` = 'refunded' and `id` > 1000 order by `id` asc limit 1000
```

The paid rows match on every page. In my run the same 1,000 ids came back on each of 20 pages until a guard in the closure stopped it. Wrap your conditions in a closure, as the docs say. A pull request that would have done it for `chunkById()` ([#59999](https://github.com/laravel/framework/pull/59999)) was closed in May with "I really don't want to mess with this on a patch release."

## What I'd use

- `lazyById()` for almost every loop over a big table. Flat memory, pages by id, eager loading works, and it reads like a collection.
- `chunkById()` when a batch is the unit of work: one `whereIn()` update per chunk, one job per chunk.
- `cursor()` only for a read-only stream on its own unbuffered MySQL connection, ideally with `toBase()`, and with nothing else querying that connection mid-loop. On PostgreSQL I'd take `lazyById()` instead: lazy fetch fixes the memory, but a stray query ends the loop without telling you.
- `chunk()` and `lazy()` when the order has to be something the ById methods can't page on, and the table is small enough that OFFSET doesn't matter.

If you have a table where `chunk()` or `cursor()` beats the ById pair, send me the schema and the numbers. I'd like to know what flips it.