Keep Running Totals in Salesforce Batch Apex with Database.Stateful
Learn how Database.Stateful preserves instance variables across Batch Apex chunks, with a worked counting example and heap limits to watch.
01 Sept 2026, 18:26 UTC

The problem: batch chunks forget their variables
Standard Salesforce Batch Apex runs in chunks. Each call to execute() gets a fresh instance of your batch class. If you declare Integer totalProcessed = 0; and increment it inside execute(), the value resets to zero on every chunk. By the time finish() runs, your total reflects only the last chunk, not the whole job.
This is a problem when you need a cumulative count, an error map, or any context that must survive across chunks.
Use Database.Stateful to carry context between chunks
Implementing the Database.Stateful marker interface tells Salesforce to serialize the batch class instance and restore it before the next execute() call. Instance variables therefore retain their values across chunks. The platform manages this serialized state internally; you do not query it directly.
This works well for:
- Counting records processed across the entire job
- Collecting field values into a map for a summary report
- Tracking which records failed so you can surface them in
finish()
Worked example: count records and capture errors
This batch processes Account records, counts every record it touches, and stores any records missing a required field. Run it from the Developer Console or VS Code with Salesforce CLI. You need the Author Apex and Modify All Data permissions.
global class AccountHealthBatch implements Database.Batchable<SObject>, Database.Stateful {
global Integer totalProcessed = 0;
global Map<Id, String> missingNameErrors = new Map<Id, String>();
global String jobSummary;
global Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Name, BillingCity
FROM Account
LIMIT 5000
]);
}
global void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account acc : scope) {
totalProcessed++;
if (String.isBlank(acc.Name)) {
missingNameErrors.put(acc.Id, 'Name is blank');
}
}
// Diagnostic checkpoint: inspect cumulative state after each chunk
System.debug('Chunk processed: ' + scope.size() +
' | Running total: ' + totalProcessed +
' | Errors so far: ' + missingNameErrors.size());
}
global void finish(Database.BatchableContext bc) {
jobSummary = 'Processed ' + totalProcessed + ' accounts. ' +
missingNameErrors.size() + ' missing names.';
System.debug(jobSummary);
// Optional: email the summary or write to a custom log object
}
}
To run it, execute anonymous Apex:
AccountHealthBatch b = new AccountHealthBatch();
Database.executeBatch(b, 200);
Watch the debug logs. Each execute() chunk should show Running total climbing by the batch size, not resetting to zero. finish() prints the final cumulative count.
Expected check: if you process 1,000 records in chunks of 200, the final debug line should show Processed 1000 accounts. If it shows 200, the class is not behaving as stateful.
Trade-offs and limits
| Factor | Behavior |
|---|---|
| Heap | The serialized instance counts toward Apex heap limits. Large collections or nested objects can push you over the limit during serialization or deserialization. |
| Retries | If a batch chunk fails and is retried, the restored state may be from before the failure. Design your logic to be idempotent where possible. |
| Visibility | Stateful batch state is not exposed as a queryable table. Verify behavior through debug logs or by writing results to a custom object in finish(). |
| Concurrency | Multiple queued jobs using the same class do not share state; each job instance is independent. |
A common mitigation is to clear large collections inside execute() after you no longer need them, or write intermediate results to a custom object instead of holding everything in memory.
Check the result and close out
After running the batch, open Debug Logs in Setup and filter for your user and the batch class name. Confirm that the running total increases across chunks and matches the final total in finish(). If you need the data beyond the job, insert a custom log record in finish() rather than relying on debug logs alone.
Database.Stateful is a focused tool, not a default. Use it when you genuinely need cross-chunk memory, keep the state small, and verify the cumulative behavior in logs before running against production volumes.
If you deploy a new version of the batch while a job is running, abort the in-flight job first to avoid mixing class versions with serialized state.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.