chunk() Skips Half Your Rows. chunkById() Can Be Worse.
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 ($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, so rows 100-199 of the original thousand are the ones you just processed - and 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.
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
chunkmethod based on a column that you will also be updating while iterating over the results, you should use thechunkByIdmethod.
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 ($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 mailing job that reached 500 people before now reaches 196, and mails 40 of them twice.
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 100The 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.
The documentation for chunking never mentions ordering at all. The word “order” appears once in the entire section, and it’s the name of lazyByIdDesc.
In practice
Use chunkById or lazyById whenever the callback writes anything that the query filters on. chunk and lazy are for reads.
Never pass your own orderBy to chunkById or lazyById. You don’t get your ordering and you don’t get correct results either. 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.