Here’s a realistic Batch class with Database.Stateful — it sums opportunity amounts per account across millions of records and writes the rollups in finish.
The class
public class OpptyRollupBatch implements
Database.Batchable<sObject>,
Database.Stateful
{
// State surviving across chunks — possible because of Database.Stateful
public Map<Id, Decimal> totalByAccount = new Map<Id, Decimal>();
public Map<Id, Integer> countByAccount = new Map<Id, Integer>();
public Integer recordsProcessed = 0;
public Integer errors = 0;
public List<String> errorList = new List<String>();
public Database.QueryLocator start(Database.BatchableContext ctx) {
return Database.getQueryLocator(
'SELECT Id, AccountId, Amount, StageName ' +
'FROM Opportunity ' +
'WHERE IsClosed = false AND AccountId != null'
);
}
public void execute(Database.BatchableContext ctx, List<Opportunity> scope) {
for (Opportunity o : scope) {
try {
Decimal amt = (o.Amount == null) ? 0 : o.Amount;
// Sum amounts per account, across chunks
if (!totalByAccount.containsKey(o.AccountId)) {
totalByAccount.put(o.AccountId, 0);
countByAccount.put(o.AccountId, 0);
}
totalByAccount.put(o.AccountId, totalByAccount.get(o.AccountId) + amt);
countByAccount.put(o.AccountId, countByAccount.get(o.AccountId) + 1);
recordsProcessed++;
} catch (Exception e) {
errors++;
errorList.add('Opp ' + o.Id + ': ' + e.getMessage());
}
}
}
public void finish(Database.BatchableContext ctx) {
// Write the rollups in bulk — totalByAccount has *all* accounts processed
List<Account> toUpdate = new List<Account>();
for (Id accId : totalByAccount.keySet()) {
toUpdate.add(new Account(
Id = accId,
Open_Opp_Total__c = totalByAccount.get(accId),
Open_Opp_Count__c = countByAccount.get(accId)
));
}
// Chunk the update if we have >10K (DML row limit)
Integer chunk = 1000;
for (Integer i = 0; i < toUpdate.size(); i += chunk) {
Integer end = Math.min(i + chunk, toUpdate.size());
List<Account> slice = new List<Account>();
for (Integer j = i; j < end; j++) slice.add(toUpdate[j]);
Database.update(slice, false);
}
// Email summary
AsyncApexJob job = [
SELECT Status, CreatedBy.Email FROM AsyncApexJob WHERE Id = :ctx.getJobId()
];
Messaging.SingleEmailMessage msg = new Messaging.SingleEmailMessage();
msg.setToAddresses(new String[] { job.CreatedBy.Email });
msg.setSubject('Opp Rollup Batch: ' + job.Status);
msg.setPlainTextBody(
'Opps processed: ' + recordsProcessed + '\n' +
'Accounts updated: ' + totalByAccount.size() + '\n' +
'Errors: ' + errors
);
Messaging.sendEmail(new Messaging.SingleEmailMessage[] { msg });
}
}
How to invoke
Database.executeBatch(new OpptyRollupBatch(), 200);
What makes this pattern strong
| Feature | Why it matters |
|---|---|
Database.Stateful | totalByAccount survives across chunks — essential for aggregation |
Map keyed by AccountId | Allows the same account to appear across multiple chunks and still aggregate correctly |
Single bulk update in finish | Writes rollups in one efficient pass after all chunks complete |
Chunked DML inside finish | Defends against the 10K DML row limit if many accounts exist |
| Per-record try/catch | Failures don’t crash the chunk |
| Email summary | Standard end-of-job notification |
A simpler stateful pattern
If they want a minimal example:
public class TaggerBatch implements Database.Batchable<sObject>, Database.Stateful {
public Integer total = 0;
public Database.QueryLocator start(Database.BatchableContext ctx) {
return Database.getQueryLocator('SELECT Id FROM Lead WHERE IsConverted = false');
}
public void execute(Database.BatchableContext ctx, List<Lead> scope) {
for (Lead l : scope) l.Description = 'Tagged ' + Date.today();
update scope;
total += scope.size();
}
public void finish(Database.BatchableContext ctx) {
System.debug('Total leads tagged: ' + total); // shows the actual cumulative total
}
}
Verified against: Apex Developer Guide — Database.Stateful Interface. Last reviewed 2026-05-17.