Batch Apex in Salesforce: Update Thousands of Accounts Safely
Facing governor limits when updating thousands of records? Batch Apex splits work into 200-record chunks, keeping you within limits. This guide shows how to build, test, and monitor a batch that updates Account.Status, plus trade-offs you should know.
26 Jan 2026, 05:37 UTC

Problem: Governor Limits on Large DML Operations
When you need to update thousands of records in a single transaction, the standard synchronous Apex DML limit of 10,000 records per transaction is quickly exceeded. Even if you stay below that number, you may hit the 200-record per DML call limit or other governor limits such as CPU time, query rows, or script statements. The result is a System.LimitException that aborts the entire operation.
Thesis: Batch Apex Splits Work into 200-Record Chunks
Batch Apex was designed to tackle exactly this problem. By implementing the Database.Batchable interface, Salesforce automatically splits your data set into manageable chunks (default 200 records per execute call). Each chunk runs in its own transaction, so you stay well within governor limits while still processing large volumes.
1. Batch Apex Basics
- Start: Returns a query locator or a collection of records to process.
- Execute: Runs for each batch of records; contains the bulkified logic.
- Finish: Runs once after all batches complete; typically used for cleanup or notifications.
Batch Apex runs asynchronously, so you won’t see immediate feedback in the UI. Instead, monitor the Apex Jobs page to track status, failures, and runtime.
2. Designing a Robust Batch Class
Below is a minimal yet complete batch that updates the Status field on Account records. It demonstrates:
- Bulkification of DML operations.
- Error handling with
Database.SaveResult. - Safety against recursion by checking a static flag.
global class UpdateAccountStatusBatch implements Database.Batchable<SObject>, Database.Stateful {
global String queryLocator;
global Boolean isBatchRunning = false;
global Database.QueryLocator start(Database.BatchableContext bc) {
// Query all accounts that need status update
return Database.getQueryLocator(
'SELECT Id, Status__c FROM Account WHERE Status__c = \'Old\' LIMIT 10000'
);
}
global void execute(Database.BatchableContext bc, List<Account> scope) {
// Prevent recursion if this batch triggers another batch
if (isBatchRunning) return;
isBatchRunning = true;
List<Account> toUpdate = new List<Account>();
for (Account acc : scope) {
acc.Status__c = 'New';
toUpdate.add(acc);
}
try {
Database.update(toUpdate, false); // partial success allowed
} catch (Exception e) {
System.debug('Batch update failed: ' + e.getMessage());
}
}
global void finish(Database.BatchableContext bc) {
// Optional: send an email or log completion
System.debug('Batch finished.');
}
}
To enqueue the batch from anonymous Apex:
UpdateAccountStatusBatch batch = new UpdateAccountStatusBatch();
Database.executeBatch(batch, 200); // 200 records per execute
Run this in the Developer Console or VS Code with the appropriate permissions. The second argument to executeBatch is optional; if omitted, Salesforce defaults to 200.
3. Testing and Monitoring
Because Batch Apex runs asynchronously, unit tests must explicitly start and stop the test context.
@isTest
private class UpdateAccountStatusBatchTest {
@isTest static void testBatch() {
// Create test accounts
List<Account> testAccs = new List<Account>();
for (Integer i = 0; i < 250; i++) {
testAccs.add(new Account(Name = 'Test ' + i, Status__c = 'Old'));
}
insert testAccs;
Test.startTest();
UpdateAccountStatusBatch batch = new UpdateAccountStatusBatch();
Database.executeBatch(batch, 100); // smaller batch size for the test
Test.stopTest();
// Verify all accounts have the new status
List<Account> updated = [SELECT Id, Status__c FROM Account WHERE Id IN :testAccs];
for (Account a : updated) {
System.assertEquals('New', a.Status__c);
}
}
}
Key points:
- Use
Test.startTest()andTest.stopTest()to enforce asynchronous behavior in tests. - Assert the final state of records to confirm all batches ran.
- Check debug logs for the number of
executecalls to confirm chunking.
After deployment, monitor the batch via the Apex Jobs page. Look for status transitions: Pending → Running → Completed. If any failures appear, click the job ID to view detailed error logs.
4. Trade-offs and Common Pitfalls
- Recursion Risk: If a trigger or another batch job calls
Database.executeBatchinsideexecute, you may hit the 5-batch limit per transaction. Use a static flag or a custom setting to guard against re-entrancy. - Limited Context: Each
executecall has its own transaction. If you need to share data across batches, implementDatabase.Statefulor persist intermediate results in a custom object. - Asynchronous Feedback: You won’t see real-time progress in the UI. Consider sending a notification email or updating a custom status field in
finishfor visibility. - Governor Limits per Batch: Even though each batch is limited to 200 records, you can still hit CPU, query, or script limits within a batch. Keep logic efficient and avoid unnecessary queries.
Actionable Next Steps
- Identify the large data set that triggers governor limits.
- Create a
Batchableclass following the pattern above, adjusting the query and DML logic. - Write a test class that inserts a representative sample, runs the batch with
Test.startTest(), and asserts final state. - Deploy to a sandbox, enqueue the batch, and verify via the Apex Jobs page.
- Set up email or custom status updates in
finishto keep stakeholders informed. - Monitor for any failures and refine error handling as needed.
By following these steps, you can safely process thousands of records without breaching governor limits, while maintaining clear visibility into batch execution.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.