Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

The two facts people get wrong: Batch Apex chunks can run concurrently, and “concurrently” tops out at five jobs per org rather than five chunks of your job. Within one batch job the chunks are processed in an order you do not control and cannot assume is sequential, which is why any batch that accumulates state across chunks is a bug waiting for a busy afternoon.

What actually runs in parallel

Up to five batch jobs execute concurrently in an org. Beyond that, jobs queue. Within a single job, chunks are dispatched by the platform and you should treat their ordering as unspecified — the practical consequence is that chunk 7 may finish before chunk 3, and two chunks may be in flight at once.

If your code assumes chunk N sees everything chunks 1..N-1 wrote, it is relying on something the platform does not promise.

The state bug this produces

The classic shape is a running total held in an instance variable.

public class RevenueRollupBatch implements Database.Batchable<sObject> {

    private Decimal runningTotal = 0;   // wrong, and intermittently wrong

    public void execute(Database.BatchableContext bc, List<Opportunity> scope) {
        for (Opportunity o : scope) {
            runningTotal += o.Amount;
        }
    }

    public void finish(Database.BatchableContext bc) {
        insert new Report__c(Total__c = runningTotal);
    }
}

Without Database.Stateful the variable resets each chunk, so the total is whatever the last chunk happened to hold. That much is documented and reasonably well known.

The part that bites later: adding Database.Stateful makes the variable persist, but it does not make concurrent accumulation safe. State is serialised between chunks, and where chunks overlap you can lose an increment. A job that has produced correct totals for months will one day be wrong by exactly one chunk, and nothing in the logs will explain it.

The fix is to stop accumulating in memory and let the database do it:

public void execute(Database.BatchableContext bc, List<Opportunity> scope) {
    Map<Id, Decimal> byAccount = new Map<Id, Decimal>();
    for (Opportunity o : scope) {
        Decimal current = byAccount.get(o.AccountId);
        byAccount.put(o.AccountId, (current == null ? 0 : current) + o.Amount);
    }
    // upsert deltas per account; aggregate at the end with a query, not a variable
}

public void finish(Database.BatchableContext bc) {
    AggregateResult ar = [SELECT SUM(Amount__c) total FROM Revenue_Delta__c];
    insert new Report__c(Total__c = (Decimal) ar.get('total'));
}

That is more code and one extra object. It is also correct under concurrency, which the elegant version is not. When a summary has to be right, I would take the ugly version every time.

The other thing concurrency breaks: row locks

Two chunks updating children of the same parent contend for the parent’s lock. Master-detail children lock their parent on write, and a transaction waits at most ten seconds before throwing UNABLE_TO_LOCK_ROW.

This is why a batch over a skewed object fails intermittently while a batch over an evenly distributed one does not. It is also why sorting by parent Id is the standard mitigation for data loads: grouping same-parent rows into the same chunk means one lock acquisition rather than several competing ones.

Scope, concurrency and the limits you are trading between

Scope Chunks over 1M rows Per-chunk risk Async budget used
10 100,000 Very low Enormous
200 (default) 5,000 Moderate Moderate
2,000 (max) 500 High — heap and CPU Small

Larger scope means fewer executions against the daily async limit and more risk of hitting heap or CPU inside a chunk. There is no universally right answer. There is a right answer for a given job, found by measuring one chunk’s limit consumption and leaving margin.

The one hard constraint: if the chunk makes a callout per record, scope must stay under 100 because callouts do not scale with the chunk.

Making a batch safe under concurrency

  • No cross-chunk accumulation in memory. If you need a total, compute it in finish from the database.
  • Use Database.Stateful only for configuration — the parameters the job was started with — not for results.
  • Order the QueryLocator by the parent Id when children share parents, so contention falls inside a chunk rather than across chunks.
  • Handle UNABLE_TO_LOCK_ROW as expected, not exceptional. Collect the failures with partial-success DML and re-process them rather than failing the chunk.
  • Never start a batch from a batch. It is not allowed, and the workaround people reach for, a queueable in finish that starts the next batch, needs its own guard against runaway chaining.

Frequently asked

Can I force chunks to run one at a time?

Not directly. If strict ordering is required, the shape you want is a queueable chain, where each job enqueues its successor and only one runs at a time. You give up throughput to get ordering.

Does Database.Stateful slow the job down?

It adds serialisation of the instance state between chunks, so keeping large collections in stateful variables is both slow and a heap risk. Keep stateful state small. Counters and parameters, not record collections.

Why did my batch fail on chunk 400 of 5,000 and stop?

It probably did not stop. An unhandled exception fails that chunk and the job continues with the next, so what you have is 4,999 successful chunks and one silent data gap. Check NumberOfErrors on the AsyncApexJob record rather than the overall status.

Do the five concurrent jobs include scheduled and queueable work?

The five-concurrent ceiling is specific to batch jobs. Queueable jobs are governed separately, which is part of why they are often the better tool for shorter work.

Does finish run if every chunk failed?

Yes. finish runs after processing completes regardless of chunk outcomes, so it must not assume the work succeeded. Read the job’s error count before acting on results.


Reference: Using Batch Apex and Execution Governors and Limits, Salesforce Apex Developer Guide.