I never truly understood the difference between chunk(), cursor(), and lazy(), so I ran a quick test: I seeded 100,000 rows into a table, then looped through all of them 3 times. Once with chunk(), once with cursor(), and once with lazy(), measuring memory usage, query count, and execution time for each one.
Here's what I learned from this test, and why the difference between these three isn't just syntax. It's a real architectural decision that affects your database connections and your app's stability under load.
The Root Problem
Here is what happens when you run something such as User::all() or User::get() on a big table:
-
The database runs the query:
SELECT * FROM users, and hands back every matching row. - PHP turns each row into an Eloquent model: a full object, sitting in memory, for every single row (this is where the issues happen).
The database doesn't struggle with this. MySQL or Postgres can have millions of rows and still be fine. The issue happens in step 2, when PHP attempts to hold every single one of those rows as an object all at once in RAM.
A single hydrated Eloquent model takes up roughly 5 KB to 10 KB of RAM. On a small app with 1000 users, holding every single row from a table as an object in memory isn't an issue. But on an app with 10,000+ users, RAM usage can easily blow past 128 MB, causing a fatal error crashing the entire operation:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 32768 bytes) in /var/www/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php on line 512
Here is a diagram visualizing the issue:
This issue is exactly why chunk(), cursor(), and lazy() exist. The 3 functions are solutions to this problem, however they have different methods of solving it, and the difference matters significantly.
chunk() - Multiple Small Trips
chunk() works by breaking one big query into smaller ones, so your server doesn't have to hold all the rows in memory at once. Instead of hydrating all the rows at once and filling up the server's memory, it performs PHP hydration multiple times, each time with a different group of rows.
User::chunk(200, function ($users) {
foreach ($users as $user) {
// process each user
}
});
Behind the scenes, Laravel actually runs something like SELECT * FROM users LIMIT 200 OFFSET 0, processes that batch, throws it away, then runs LIMIT 200 OFFSET 200. It keeps repeating the process like a loop, until all of the rows are hydrated.
The Twist
When deleting or updating rows while looping, the OFFSET shifts (let's say you're deleting 200 rows at a time, the first 200 rows get deleted, now index 0 has shifted). This causes some rows to get skipped and others processed twice.
The Fix: chunkById()
User::chunkById(200, function ($users) {
// safe even if you delete rows inside the loop
});
Instead of paginating by offset (get the next 200 rows), chunkById() paginates by key (get rows where id > last id processed). This way, if rows get updated or deleted, it doesn't affect the process, since it's based on the row's actual identity.
Here is a clear side-by-side comparison between chunk() and chunkById() visualizing what happens when a record gets deleted:
cursor() - One Row at a Time
foreach (User::cursor() as $user) {
// process each user
}
cursor() works like streaming a YouTube video. It sends one query to the database requesting all of the data, but gets each row one at a time, processes it, discards it, then moves to the next one. This way, cursor() uses up virtually zero memory from your server, no matter how significant the amount of data is. It sounds amazing at first, however, understanding when cursor() should not be used and when it can be useful is very critical.
When NOT to Use cursor()
Since it's one query for the entire dataset, the database connection remains busy. This matters a lot:
Slow work inside the loop: If you're doing heavy processing such as calling an external API per row, the connection stays reserved for as long as the loop takes. Let's say you have to process 2000 rows, and it takes 1 second of processing per row. That means the database connection remains completely unusable for 2000 seconds, which is about 33 minutes! If you have a large app with more than 10,000 rows to process, that's about 3 hours.
Limited database connections: During production, it is common for apps to use a limited connection pool. A
cursor()loop holding open the connection for a long time can affect other parts of the app, limiting their DB connections.
When cursor() Can Be Useful:
cursor() can be very useful in specific situations, where you must prioritize your server's memory over speed. Here are examples where it would be a lifesaver:
Exporting massive data: Let's say you need to generate a CSV file with 500,000 rows. A standard query would crash your server due to memory overload.
cursor()effortlessly processess all the rows one by one, while keeping memory usage at virtually zero.Low traffic background jobs (queues): Running a loop in a background script (such as a Laravel Queue) late at night where not many users are around doesn't affect anything since there aren't actual users competing for DB connections.
lazy() - The Most Misunderstood One
User::lazy()->each(function ($user) {
// process each user
});
Many developers share a very common misconception: lazy() behaves just like cursor(), using a single query.
In reality, lazy() uses the exact same mechanism as chunk(), processing multiple rows at a time. The only difference is on the PHP side. lazy() returns a LazyCollection, giving you access to methods such as map(), filter(), and each().
User::lazy()
->filter(fn ($user) => $user->isActive())
->each(fn ($user) => $user->sendNewsletter());
The Same Twist
Since lazy() follows the same architecture as chunk(), it comes with the same mutation issue. When updating or deleting rows, some could be skipped or processed twice. The fix to this is lazyById(), which paginates using row ID instead of offset, just like chunkById().
User::lazyById(200)->each(function ($user) {
// safe even if you delete rows inside the loop
});
chunk() vs cursor() vs lazy() Summarized
Here is a clear summary of the comparison between the three methods:
| Method | Query pattern | Description |
|---|---|---|
chunk() |
Many small queries | Offset-based pagination, batch by batch |
cursor() |
One query | PHP generator that streams one row at a time |
lazy() |
Many small queries | Same mechanism as chunk(), chainable on top |
Benchmark: The Ultimate Plot Twist
To see the true differences between the three methods in action, I benchmarked them against 100,000 rows (used the same 100,000 rows for each method), measuring peak memory, number of queries, and execution time. If you want to test out this benchmark yourself or check out the setup, the full implementation is on GitHub: https://github.com/EliasAlrgeaiDev/laravel-chunk-cursor-lazy-benchmark
$startMemory = memory_get_usage();
$startTime = microtime(true);
$queryCount = 0;
DB::listen(function () use (&$queryCount) {
$queryCount++;
});
// run the loop here
$peakMemory = memory_get_peak_usage() - $startMemory;
$executionTime = microtime(true) - $startTime;
Here is a table displaying the benchmark results:
| Method | Peak memory | Query count | Execution time |
|---|---|---|---|
chunk() |
1.52 MB | 501 queries | 19.44s |
cursor() |
17.6 MB | 1 query | 19.86s |
lazy() |
1.52 MB | 501 queries | 19.39s |
The Plot Twist
cursor() is supposed to be the one using the least memory, processing one row at a time, holding almost nothing in memory. But it completely caught me off guard when it used up more memory than chunk() and lazy() combined.
The cause: Laravel thinks it's doing the right thing. However, the query didn't actually hit the database. It was given to PHP's PDO (PHP data object), which by default fetches and holds the entire dataset into memory at once, then gives Laravel each row one at a time, defeating the entire purpose of using cursor().
**Note for non-MySQL devs:** This behavior only happens with MySQL databases, so if you use something else such as PostgreSQL or SQLite, you don't have to worry about any of this.
The fix is to tell PHP to stop hoarding all the data. This forces it to pull only one row at a time from the database, like how it's supposed to. To apply the fix, open config/database.php, go to the MySQL connection array, and update options to this:
// config/database.php, inside the mysql connection array
'options' => [
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => false,
],
When I re-ran the benchmark, cursor() behaved just like expected, using the least memory:
cursor: 1.48 MB | 1 queries | 20.05s
Why use chunk()?
Since chunk() and lazy() performed almost exactly the same, then why does chunk() even exist if lazy() comes with nicer chainable methods while putting up the same numbers? lazy() is the latest one and the more ideal to use, however chunk() does come with 2 benefits in specific scenarios:
Batch level logic:
chunk()lets you handle the entire batch as one group, like sending a notification every 200 rows processed, whilelazy()only allows you to handle each row separately, so you need to write more syntax to mimic the same design.Legacy code:
lazy()was introduced in Laravel 8, so older projects heavily rely onchunk(). Continuing its use keeps things consistent.
Wrapping up
chunk(), cursor(), and lazy() all solve the same memory issue, but they have their own methods of solving it. chunk() and lazy() follow the same architectural idea, processing the dataset in small trips. The only difference is that lazy() comes with more chainable methods, while chunk() is better if you need batch-level logic or you're working with a legacy codebase. cursor() works completely different, sending one big query requesting the entire dataset, but recieving each row one at a time.
Here is a reference summarizing the differences between the 3 methods:
| Situation | Use |
|---|---|
| Reading only, no chaining needed | chunk() |
| Mutating rows mid-loop |
chunkById() or lazyById()
|
Need Collection methods (map, filter, etc.) |
lazy() |
| Lowest memory usage | cursor() |
None are wrong. It only depends on your need. Whether you prioritize saving memory, keeping old code consistent, or chainable methods, all three solve the same memory issue.
If you want to test out this benchmark yourself or check out the setup, the full implementation is on GitHub: https://github.com/EliasAlrgeaiDev/laravel-chunk-cursor-lazy-benchmark


Top comments (0)