Skip to content

When chunkById() Is Worse Than chunk()

September 16, 2026 • 6 min read

You have 1,000 pending subscribers and a job that marks each one as sent. It finishes without an error and exits 0. Afterwards, 500 subscribers are still pending.

Subscriber::query()->where('status', 'pending')->chunk(100, function (Collection $subscribers) {
    $subscribers->each->update(['status' => 'sent']);
});

Exactly half. The other 500 were never returned by any query the loop ran.

The row counts below are from a real run against a subscribers table of 1,000 rows, every one of them pending, with ids 1 to 1,000 in insertion order and created_at values scattered so that date order and id order don’t line up.

Half the table, silently

+-----------------------------------+------+--------+---------------+
| method                            | seen | unique | still pending |
+-----------------------------------+------+--------+---------------+
| chunk(100) + update status        |  500 |    500 |           500 |
| lazy(100) + update status         |  500 |    500 |           500 |
| chunkById(100) + update status    | 1000 |   1000 |             0 |
| lazyById(100) + update status     | 1000 |   1000 |             0 |
+-----------------------------------+------+--------+---------------+

chunk pages with OFFSET and LIMIT. Page one asks for rows 0-99 of everything matching status = 'pending', and the callback then updates those hundred rows to sent. They leave the result set. Page two asks for rows 100-199 of a set that is now 900 rows long, which are rows 200-299 of the original thousand. The hundred that were sitting at positions 100-199 before the update have slid down to 0-99, where nobody will look again.

Every chunk you process pushes an equal number of unprocessed rows behind the offset. You skip one for every one you handle, which is why it lands on exactly half. The ids it actually processed were 1-100, 201-300, 401-500, 601-700 and 801-900.

lazy() behaves identically. It reads nicer and people reach for it as the modern replacement, but underneath it is the same OFFSET walk with the same hole.

None of this happens when the callback leaves the filter alone. Read-only, chunk sees all 1,000, so a fixture that only reads will never reproduce it.

The documented fix

The docs do warn about this:

If you are filtering the results of the chunk method based on a column that you will also be updating while iterating over the results, you should use the chunkById method.

chunkById doesn’t count offsets. It remembers the last id it saw and asks for rows after it, so a shrinking result set can’t shift anything out from under it. The table above confirms it: 1,000 of 1,000.

That fix is correct, and it has an edge the docs don’t mention.

Now add an orderBy

Same chunkById, same data, one extra line - and this time the callback updates nothing at all:

Subscriber::query()->where('status', 'pending')
    ->orderBy('created_at', 'desc')
    ->chunkById(100, function (Collection $subscribers) {
        // read only - nothing is modified
    });
+------------------------------------------+------+--------+------------+
| method (callback modifies nothing)       | seen | unique | duplicates |
+------------------------------------------+------+--------+------------+
| chunk + orderBy                          | 1000 |   1000 |          0 |
| chunkById                                | 1000 |   1000 |          0 |
| chunkById + orderBy                      |  236 |    196 |         40 |
| lazyById + orderBy                       |  236 |    196 |         40 |
+------------------------------------------+------+--------+------------+

196 of 1,000 rows, and 40 of them handed to you twice. The same numbers come back on every run. Nothing was written and the result set never shrank. Plain chunk with that identical orderBy still sees all 1,000. The ById variants are the ones that break here, and they break harder than the bug they were brought in to fix. A newsletter job that only reads its subscribers reaches 196 of them and mails 40 twice. The status-updating job from the first example does no better: with the same orderBy, chunkById reaches 301 subscribers, fewer than the 500 plain chunk managed. It sends nobody twice only because a row already marked sent no longer matches the query.

Why

chunkById calls forPageAfterId, which strips existing orders on the key column only, then appends its own. Your sort survives as the primary one. Here are the three queries it actually ran:

select * from `subscribers` where `status` = 'pending'
  and `id` is not null order by `created_at` desc, `id` asc limit 100

select * from `subscribers` where `status` = 'pending'
  and `id` > 700 order by `created_at` desc, `id` asc limit 100

select * from `subscribers` where `status` = 'pending'
  and `id` > 964 order by `created_at` desc, `id` asc limit 100

The cursor and the sort order disagree. Rows come back ordered by created_at, so a single page holds ids scattered across the whole table - page one’s hundred rows run from id 25 to id 999. But the cursor is the id of whichever row happened to land last in that page, which here is 700 - a row somewhere in the middle of the id range, chosen by date.

Page two then says id > 700 and discards, in one step, the 624 rows below 700 that page one didn’t happen to include. They are never visited and never reported. At the same time it re-serves the 24 page-one rows whose ids were already above 700; the same overlap repeats between pages two and three, for 40 duplicates in total. Page three narrows to id > 964, returns 36 rows, and the walk ends there having seen 236.

Keyset pagination only works when the cursor column is the column you’re ordering by. chunkById relies on that and never checks it.

How much you lose depends on how your sort column happens to correlate with the id - a different scattering gives a different number. What doesn’t change is that you get an incomplete set with duplicates in it, and no indication that anything went wrong.

Neither chunking section of the docs warns about this. The Query Builder page only uses orderBy('id') in its chunk() examples, and on the Eloquent page the word “order” appears once, in the sentence introducing lazyByIdDesc.

In practice

Use chunkById or lazyById whenever the callback writes anything that the query filters on. chunk and lazy are fine as long as the callback leaves those columns alone.

Don’t give chunkById or lazyById an orderBy on any other column. Each page comes back in your order, but the walk from one page to the next skips and repeats rows. Ordering by the id itself is harmless, since it’s removed and re-added anyway, though orderBy('id', 'desc') quietly comes back ascending. chunkByIdDesc() and lazyByIdDesc() are the methods for walking backwards. If you need work done in a particular order, sort somewhere the pagination can’t see it - order within each chunk, or select the ids up front and walk them yourself.

Group your own conditions in a closure. chunkById appends and id > ? to your where, so a trailing orWhere at the top level swallows the cursor along with everything else. The docs flag this one.

Count what you actually processed. Take a count before the loop, tally the rows your callback handles, and compare the two when it finishes. If they don’t match, fail the job instead of letting it exit green.