200 is the default, not the right answer. Scope should be set by the heaviest record your batch will see, not the average one, because a single execute that exceeds CPU or heap kills that chunk and everything in it. Smaller is safer, up to the point where the per-chunk overhead starts costing more than it saves.
What scope actually controls
Database.executeBatch(new MyBatch(), 50) sets how many records each execute() receives. Each execute is its own transaction with its own governor limits: 100 queries, 150 DML statements, 10,000 DML rows, 60,000ms CPU and 12MB heap, since batch runs asynchronously.
The query rows returned by start() are not limited to 50,000. The start method uses a QueryLocator, which can return up to 50 million records, and those are handed out in scope-sized chunks. That is the whole point of batch Apex.
Size for the worst record
The mistake is averaging. You test with 200 typical accounts, each with three contacts, and everything passes. In production one account has 4,000 contacts, and the execute containing it blows the heap.
Work out the per-record cost of the heaviest case you can plausibly receive:
- How many child records does the biggest parent have?
- How many DML rows does one input record produce? With a 10,000 DML row limit, a record generating 60 children caps your scope at 166.
- How much heap does one record’s object graph take, against 12MB?
- Does the logic call out, and how long does the endpoint take?
Then divide. If the heaviest record produces 40 DML rows, a scope of 200 produces 8,000, which fits but leaves little margin. A scope of 100 is comfortable.
The cost of going too small
Small scope is not free. Every execute pays for itself:
- Fixed setup per chunk. Class instantiation, describe calls, custom setting reads, anything your execute does once regardless of record count.
- More chunks means more total queries. A batch over 1,000,000 records at scope 200 is 5,000 executes. At scope 10 it is 100,000. If each execute runs three queries, that is 300,000 queries instead of 15,000.
- Longer wall clock. Each execute has scheduling overhead, and a job with 100,000 chunks takes considerably longer than one with 5,000.
- Concurrency pressure. Long-running jobs occupy the flex queue and compete with everything else you run.
A scope of 1 is the emergency setting. It guarantees a bad record only fails itself, and it is the right tool for rescuing a load, not for running nightly.
Practical starting points
- Simple field updates, no children, no callouts: 200. The default is correct here.
- Queries related records per input record: 50 to 100, depending on the child count you can expect.
- Makes a callout per record: 10 or fewer. The 120 second cumulative callout budget per transaction is the constraint, not the record count.
- Heavy calculation or large payload construction: 20 to 50, and instrument before committing to a number.
- Recovering from a batch that failed on bad data: 1, with partial-success DML.
Measure rather than guess
Log the limit consumption at the end of each execute:
public void execute(Database.BatchableContext bc, List<Account> scope) {
Integer cpu = Limits.getCpuTime();
// work
System.debug(LoggingLevel.ERROR,
'size=' + scope.size() +
' cpu=' + (Limits.getCpuTime() - cpu) + '/' + Limits.getLimitCpuTime() +
' heap=' + Limits.getHeapSize() + '/' + Limits.getLimitHeapSize() +
' dmlRows=' + Limits.getDmlRows());
}
Run once at the default and read the peaks, not the averages. If the worst execute used 40,000ms of a 60,000ms CPU allowance, you are one unusual record from failing. Halve the scope.
Scope and stateful batches
With Database.Stateful, instance variables survive across executes. That means a running total or an accumulating list grows with the number of chunks, and heap is measured per execute but the stateful collection is carried into each one. A stateful list that grows by 200 entries per chunk over 5,000 chunks will fail eventually. Keep stateful members to counters and small summaries.
Frequently asked
What is the maximum scope? 2,000 for a QueryLocator start method, and 200 when start returns an Iterable. Going above 200 is rarely useful because the limits per execute do not increase.
Does a failed execute stop the job? No. That chunk rolls back and the job continues with the next. This is why scope size determines blast radius.
Can I change scope mid-job? No. You would have to abort and re-run with a new value.
Does scope affect the 50 million record start limit? No, that ceiling applies to what the QueryLocator returns, independent of how it is chunked.
