log in
consulting hosting industries the daily tools about contact

Chunking vs. Lazy Collections in Laravel: The Memory Difference That Matters

I ran both approaches against a 4M-row MariaDB table and the numbers weren't even close. Here's what actually happens under the hood.

I've been running a data processing job against a MariaDB table with about 4 million rows — order history for a regional e-commerce client — and I finally sat down to measure the actual memory difference between chunk() and lazy(). The results surprised me enough that I'm changing how I approach this by default.

The short version: lazy() is not just a syntactic convenience. The memory profile is genuinely different, and on large tables it can be the difference between a job that runs and one that OOMs your server at 2am.

What We're Actually Talking About

When you need to process more rows than you can fit in memory, Laravel gives you two main tools:

  • chunk() — pulls N rows at a time, hands you a Collection, calls your closure, discards that Collection, pulls the next N rows. Repeat.
  • lazy() (and lazyById()) — returns a LazyCollection backed by a PHP Generator. Rows trickle in one at a time as you iterate.

Both avoid loading the whole table into memory. That's where the similarity ends.

The documentation describes both approaches and even recommends lazy() for large datasets, but it doesn't really explain why or show you the numbers. I'll do that.

The Setup

Here's the table I'm working against. About 4.1 million rows, nothing exotic:

CREATE TABLE orders (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  customer_id BIGINT UNSIGNED NOT NULL,
  status VARCHAR(32) NOT NULL,
  total DECIMAL(10,2) NOT NULL,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  INDEX idx_status (status),
  INDEX idx_created_at (created_at)
);

The job I need to run: flag orders over 90 days old with status pending as abandoned, then write a summary row somewhere. Classic batch update scenario.

The chunk() version

$before = memory_get_usage(true);

Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDays(90))
    ->orderBy('id')
    ->chunk(1000, function (Collection $orders) {
        foreach ($orders as $order) {
            // process $order
        }
    });

$after = memory_get_usage(true);
Log::info('chunk() peak: ' . memory_get_peak_usage(true));

The lazy() version

$before = memory_get_usage(true);

Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDays(90))
    ->orderBy('id')
    ->lazy(1000)
    ->each(function (Order $order) {
        // process $order
    });

$after = memory_get_usage(true);
Log::info('lazy() peak: ' . memory_get_peak_usage(true));

Notice both take a chunk size of 1000. That matters — I'll come back to it.

The Actual Numbers

I ran these with memory_get_peak_usage(true) logging enabled, on a subset of about 380,000 matching rows (the pending ones older than 90 days). PHP 8.2, Laravel 11, MariaDB 10.11.

Approach Peak Memory
chunk(1000) ~42 MB
lazy(1000) ~6 MB

Seven times less peak memory. On 380,000 rows. If that table were 4M rows and all of them matched, chunk() would have pushed past 400MB. The lazy version stays flat.

Why the Gap Is That Big

Here's what chunk() actually does per iteration:

  1. Executes a SELECT ... LIMIT 1000 OFFSET n (or keyset pagination if you use chunkById()).
  2. PDO fetches all 1000 rows into a PHP array.
  3. Eloquent hydrates all 1000 model instances.
  4. Laravel wraps them in a Collection object.
  5. Your closure runs against that Collection.
  6. The Collection and all 1000 models get garbage collected.
  7. Repeat.

So at peak, you have 1000 fully-hydrated Eloquent models plus the Collection overhead sitting in memory simultaneously.

lazy() with a generator does this instead:

  1. Executes the query with LIMIT 1000.
  2. Fetches one row at a time from the result set using PDOStatement::fetch() in a loop.
  3. Hydrates one model.
  4. Yields it to your code.
  5. When your code asks for the next item, it fetches the next row.
  6. When the 1000-row buffer is exhausted, it runs the next query.

The key difference: at any given moment, you have one hydrated model in memory, not 1000. The Collection object never exists. That's where those 36 MB went.

The Gotchas That Will Bite You

1. lazy() holds a database cursor open.

The generator keeps a live PDO statement handle open for the duration of the iteration. If your processing inside the loop does anything that modifies the same table or takes a long time, you can hit lock contention or MySQL's wait_timeout. I hit this on a job that was doing synchronous HTTP calls inside the loop — the cursor sat idle for 30 seconds between rows and MariaDB eventually killed the connection mid-iteration.

Fix: if your per-row work is slow or involves mutations on the same table, chunkById() is safer. It re-queries per chunk, so there's no persistent cursor.

2. lazy() without lazyById() can miss or double-process rows if you update during iteration.

This is the same issue as plain chunk() vs chunkById(), but worth repeating. If you're updating the status column you're filtering on while iterating, and you're using offset-based pagination under the hood, rows shift. Use lazyById() or chunkById() when mutating the dataset you're iterating.

// Safer for update-while-iterating scenarios
Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDays(90))
    ->lazyById(1000)
    ->each(function (Order $order) {
        $order->update(['status' => 'abandoned']);
    });

3. The chunk size still matters for lazy().

Some people assume lazy() renders chunk size irrelevant since you're processing one row at a time anyway. It doesn't. The chunk size controls how many rows get fetched from MariaDB per round-trip. Too small (say, 100 on 4M rows) and you're making 40,000 queries. Too large (10,000) and you're buffering more in the PDO layer before the generator yields. I've found 500–2000 is the right range for most of what I work with.

4. You can't use lazy() inside a transaction that also writes to the same table in some engine configurations.

This is MariaDB-specific. Depending on your isolation level and whether you're using InnoDB with MVCC, holding a read cursor open while writing in the same transaction can produce inconsistent reads. I haven't been burned by this in practice, but I'm aware of it. When in doubt, process and write in separate transactions.

5. Memory profiling with memory_get_peak_usage() can mislead you if PHP's allocator holds pages.

PHP's memory allocator doesn't always release pages back to the OS immediately after a Collection gets GC'd. If you're looking at system-level memory (e.g., what top shows), the numbers look worse for chunk() than memory_get_peak_usage(true) suggests, and then they plateau in a confusing way. Trust memory_get_peak_usage(true) over system metrics for this comparison.

When I'd Reach for Each

Reach for lazy() / lazyById() when:

  • The row count is large (say, 100K+) and memory is a real constraint.
  • You're on shared hosting or a container with a strict memory limit.
  • You're processing rows one at a time anyway and don't need the full batch context.
  • You want clean, readable pipeline-style code with collection methods chained on a LazyCollection.

Reach for chunk() / chunkById() when:

  • Your per-chunk logic genuinely needs the full batch (bulk inserts, batch API calls, aggregations over the chunk).
  • The processing inside the loop is slow or makes external calls — you don't want a long-lived cursor.
  • You're modifying the table mid-iteration and want re-querying-per-chunk safety.
  • Memory isn't tight and simplicity matters.

For the order flagging job I described, I switched to lazyById(). The job runs inside a 128MB PHP-CLI limit on that server. With chunk(1000) it was bumping against that ceiling on busy days when there were more pending rows than usual. With lazyById(1000) it sits at 6MB peak regardless of row count. That's the kind of headroom I want.

One More Thing Worth Knowing

If you're doing read-only reporting and you want to squeeze even more, combine lazy() with ->select() to avoid hydrating columns you don't need, and consider ->withoutGlobalScopes() if you have soft deletes or tenant scopes adding JOIN overhead:

Order::withoutGlobalScopes()
    ->select(['id', 'customer_id', 'total', 'created_at'])
    ->where('status', 'pending')
    ->where('created_at', '<', now()->subDays(90))
    ->lazyById(1000)
    ->each(function (Order $order) {
        // only the four columns exist on this model instance
    });

Each hydrated model now carries four scalar values instead of a full row. On wide tables — I've got one in a LIMS integration with 60+ columns — this alone can cut per-model memory in half.


Laravel's documentation on this is accurate but thin on why. The Generator-based approach isn't just a style preference — it's a structurally different memory model. Once you understand that only one model lives in memory at a time, the 7x difference makes complete sense. For anything over 100K rows, lazy() is my default now unless I have a specific reason to need the batch.

Related

Need help shipping something like this? Get in touch.