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
finishfrom the database. - Use
Database.Statefulonly for configuration — the parameters the job was started with — not for results. - Order the
QueryLocatorby the parent Id when children share parents, so contention falls inside a chunk rather than across chunks. - Handle
UNABLE_TO_LOCK_ROWas 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
finishthat 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.