Why Chunking a Scan-Bound Job Makes It Slower, and How to Split It Instead

Date:

You split a long batch job into chunks so a crash costs one chunk instead of the whole night, and the job now takes many times longer. If the real cost is a scan that every chunk repeats, chunking didn’t divide the work. It multiplied it.

The short answer: Chunking helps only when each chunk does less work than the whole. When the dominant cost is a fixed scan (a query that reads the same large table no matter how many output items you ask for), splitting by the output makes every chunk repeat that scan. One pass becomes N passes, and the job gets slower as well as more complex. Before you chunk, find the dominant cost. If it’s a fixed scan, chunk along the column that partitions the scanned rows, so each chunk reads a disjoint slice. And when you estimate, remember the tell of a scan-bound job: its runtime barely grows with item count, so a small sample over-predicts the whole.

The instinct, and the trap

A long job feels risky, so you reach for chunking: split the work into pieces, process each, checkpoint as you go. If it dies, you’ve lost one chunk, not the whole run. Most of the time that instinct is right.

It pays off only if a chunk is genuinely cheaper than the whole. That holds when cost scales with the items you’re processing. It fails badly when the dominant cost is a fixed scan that every chunk has to repeat. Then one scan becomes N scans, and you’ve made the job slower and harder to follow in the name of saving it.

Chunking divides per-item work. It multiplies fixed work. Know which one dominates before you split.

So the question before chunking is not “how do I split the items?” but “what actually costs the time: the items, or the scan underneath them?”

A worked example

Picture a nightly export that attaches product details to a batch of records before writing them out, for a catalog spread across several shops. The worry is legitimate: the job writes all of its output at the very end, so a crash an hour in produces nothing. Chunking to save partial output looks obvious. Split the records into groups, process each group, write as you go.

Read the query first, though. The collector does something like this:

SELECT ...
FROM   items i
JOIN   item_details d USING (item_id)
WHERE  i.store_id = ANY (:stores);
Code language: SQL (Structured Query Language) (sql)

It scans every item in the shops involved and matches what it needs in code, however many target records you asked for. The number of output records never touches the size of that scan, and EXPLAIN on the statement shows it: the plan reads the whole table, and the result count never enters its cost.

Measuring, rather than assuming, settles it. In one real run of a job shaped like this:

  • 150 records took ~26 minutes.
  • 2,930 records took ~38 minutes.

Nineteen times the records, and barely more time. The cost is the scan underneath (hundreds of thousands of rows), and that scan is fixed. Next to it, the per-record work is noise.

Now hold the chunking plan up against those numbers. Splitting by records into ~20 chunks of 150 means 20 full scans. Twenty scans at ~26 minutes each is roughly nine hours (projected), to make durable a job that runs in ~38 minutes whole. The “safety” feature would have been a 14× slowdown.

Three horizontal bars comparing wall-clock time for the same batch job under three strategies. Running the whole job in one pass takes about 38 minutes (measured). Chunking along the scan axis, store by store, also takes about 38 minutes (measured), because it is the same single scan, and adds durable per-store checkpoints. Chunking along the output axis, by record in roughly twenty chunks, is projected at about nine hours, because each chunk repeats the entire store scan. The output-axis bar dwarfs the other two.

Why the instinct misfires

The chunking instinct is trained on per-item work: parse a million rows, call an API per record, resize a folder of images. There, a chunk really is a fraction of the job, and checkpointing between chunks is close to free. For that shape of job, the training is correct.

A scan-bound job breaks the assumption silently, because nothing about the code looks different. You still have “items” to loop over. What’s hidden is that the expensive thing sits outside the loop: it’s the one query that fills it, and that query costs the same whether you ask for a hundred items or all of them. The loop you can see is cheap. The scan you forgot to account for is the bill.

If a job’s runtime barely grows when you feed it 19× the items, the items aren’t what it’s spending on.

The fix: chunk along the scan, not the output

If you do need to chunk for resumability, split along the column that partitions the scanned data, so each chunk reads a disjoint slice of it. The output axis re-reads the same data every time.

In the example that column is store. The scan already filters by store_id, so processing one shop at a time reads each shop’s items exactly once. Together the chunks perform the same total scan as the combined run, and each shop’s output is written the moment its slice finishes. You get durable partial output at shop granularity, almost for free:

  • Chunk by record (the output): N chunks → N× the scan.
  • Chunk by store (the scan’s own partition key): N chunks → 1× the scan, with N checkpoints.

Same resumability, opposite cost. All that changes is picking the split the scan was already divided along, instead of the one the output happened to suggest. It’s the habit behind profiling before you blame the obvious suspect: the slow part is rarely the part you were looking at.

Sometimes the right answer is no chunking at all. In that same run, the whole multi-phase job finished in ~74 minutes start to finish, because every phase was scan-bound. Nine projected hours had been on the table for a checkpoint the job turned out not to need.

Estimating a scan-bound job

The same fixed-scan shape wrecks estimates, in the opposite direction. Measure a small sample (“150 records in 26 minutes”) and the natural extrapolation is linear: thousands of records must take hours. For a scan-bound job that’s badly wrong, because the sample already paid almost the entire fixed cost. A better estimate comes from measuring the scan once and treating the item count as a rounding error on top of it.

A scan-bound job barely grows with item count, so a small sample over-predicts the whole rather than under-predicting it.

It’s the cost-model mistake behind billing by the wrong unit: the number that drives the total isn’t the number you were counting. Find the unit the cost actually scales with (here, scans, not records) and both your estimate and your chunking strategy follow from it.

The short version

  • Chunking helps only when a chunk does less work than the whole.
  • Test for a fixed scan first. Run a small sample and a large one. If runtime barely moves (150 records in ~26 min, 2,930 in ~38 min), the items aren’t the cost.
  • Chunk along the scanned data’s partition key (store, date range, shard key) so the chunks together read it once, with a checkpoint per chunk. Never chunk along the output, which re-scans every time.
  • Estimate from the fixed scan, not a linear extrapolation of a sample, or you’ll predict hours for a job that takes minutes.

Frequently Asked Questions

How do I tell if my job is scan-bound before I chunk it?

Run it over a small sample and over a much larger one, and compare wall-clock time. If 19× the items costs barely more time, the cost is a fixed scan, and chunking by item count will multiply that scan. If time scales roughly with item count, the job is per-item and ordinary chunking is safe. EXPLAIN on the main query confirms it: a sequential scan of a large table whose cost ignores your batch size.

Isn’t chunking still worth it just for resumability, even if it’s slower?

Only if “slower” means a few percent. In the measured case, chunking by the output axis would have turned a ~38-minute job into a projected ~9 hours, and nobody should trade a 14× slowdown for a checkpoint. Chunk along the scan’s own partition key instead and you get the resumability and the single scan.

What if my job has no natural partition key?

Then chunking can’t avoid the re-scan, so weigh the alternatives. Make the job write its output incrementally as it goes, so a crash keeps what was finished. Or cut the scan itself, with an index or a narrower WHERE clause, so a repeat costs less. Often the simplest answer is to run it whole and rerun on failure, since one rerun of a 38-minute job is still cheaper than twenty scans.

Share post:

Subscribe

Popular

More like this
Related

Replace the WordPress Logo With Your Site Icon, Everywhere It Shows

Changing the login logo is the well-known fix. The WordPress mark also sits in the toolbar, the login popup, every tab title and the admin footer, and on a multisite network each site should show its own icon, not one shared logo.

How to Show a WordPress Shortcode Without Running It

You write a tutorial that mentions a shortcode in a code example, and the published page shows something else: a gallery, a block of other posts, or an empty gap where the example should be. WordPress ran the shortcode you were only trying to mention.

How to Customize the tagDiv Newspaper Theme When the Accent Color Won’t Change

You change the Newspaper theme's accent color in the Theme Panel, save, and the header, menu and footer are still the old red. Then you try to adjust the phone layout outside the Composer and find every setting stored as base64-encoded JSON. Both have a reliable fix, step by step.

How to Update Rank Math SEO Fields Through the REST API Without Exposing Your Focus Keywords

You send a meta description to a post's REST API endpoint, WordPress replies with HTTP 200, and the field in the Rank Math panel is still empty. One function call per field fixes the write. Done the obvious way, it also publishes every focus keyword you chose.