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 offer as a way to "significantly reduce your application's memory consumption", finished behind both ById methods and held 100.4 MB.
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 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:
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 (July 2026) says it "uses chunkById()" under the hood, Rivercrane (May 2025) that it "internally behaves like cursor()", and Gold Lapel (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:
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 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). 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:
'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:
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). 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: 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 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 say that setting PDO::ATTR_PREFETCH to 0 on a PostgreSQL connection "enters lazy fetch mode":
'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 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 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, 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.
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:
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) 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: onewhereIn()update per chunk, one job per chunk.cursor()only for a read-only stream on its own unbuffered MySQL connection, ideally withtoBase(), and with nothing else querying that connection mid-loop. On PostgreSQL I'd takelazyById()instead: lazy fetch fixes the memory, but a stray query ends the loop without telling you.chunk()andlazy()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.
Common questions
- Should I use chunk(), lazy() or cursor() for a big table?
- Use lazyById(), or chunkById() when you want the rows in batches, and group any orWhere() in a closure first. Over 1,000,000 MySQL rows they took 22.3 and 21.9 seconds with under 6 MB of PHP memory. chunk() and lazy() took about six times as long, and cursor() held 100.4 MB.
- Is Laravel's lazy() the same as chunkById()?
- No. lazy() fetches each page with OFFSET, like chunk(); lazyById() is the one that pages by id, like chunkById(). Over 1,000,000 MySQL rows, lazy() took 133.7 seconds and lazyById() 22.3.
- Why does cursor() still use so much memory?
- cursor() hydrates one model at a time, but pdo_mysql buffers the whole result set when the query runs. Over 1,000,000 rows that was 100.4 MB of PHP memory. With Pdo\Mysql::ATTR_USE_BUFFERED_QUERY set to false on its own connection it was 3.3 MB, but no other query can run on that connection until every row is read.
- Can I eager load relationships with cursor()?
- No. cursor() ignores with(), so each relation you touch runs its own query: 10,001 queries for the first 10,000 rows of the table in my test. lazyById() eager loads per page and ran 20 over the same rows.
- Why does chunk() skip records when I update them?
- chunk() asks for the next page by OFFSET. If the update moves rows out of the query's filter, the offset steps over rows that still match. With 10,000 matching rows, chunk(1000) updated 5,000 of them. chunkById() and lazyById() updated all 10,000.
- How do I stream a PostgreSQL result in Laravel without buffering it?
- On PHP 8.5, set PDO::ATTR_PREFETCH to 0 in the options of a separate connection. The process then peaked at 88.9 MB instead of 324.8 MB over 1,000,000 rows. But a query run on that connection inside a cursor() loop (a relation load in my test) ended the loop after one row without an error, so lazyById() is the safer choice.
Stay in the Loop
Get the latest posts delivered to your inbox - on your schedule.