Apex Batchable: Processing Millions of Records Without Hitting Governor Limits
How Database.Batchable splits million-record jobs into governor-limit-isolated chunks: the start/execute/finish contract, QueryLocator vs Iterable, batch sizing, and partial-failure handling with a worked example.
08 Oct 2025, 21:28 UTC

When a synchronous trigger or controller can't touch more than 50,000 SOQL rows or 10,000 DML rows, the standard answer is Database.Batchable: an interface that splits a large job into chunks, each running with a fresh set of governor limits. A single batch can process up to 50 million records, and each chunk gets its own 100 SOQL queries, 150 DML statements, and 60 seconds of CPU time.
The three-method contract
Implementing Database.Batchable<SObject> requires three methods, each with a distinct role:
- start() — runs once and defines what gets processed. It returns either a
Database.QueryLocatoror anIterable<SObject>. - execute() — runs once per chunk of records (the "scope"). This is where your business logic lives.
- finish() — runs once after all chunks complete, for cleanup, notifications, or chaining the next job.
State does not automatically persist between execute() calls. Instance variables are reset unless the class also implements Database.Stateful, and static variables are always reset. Anything you need in finish() — like an error count — must either be Stateful or re-queried.
A worked example: bulk status update with partial-failure logging
This batch resets a flag on pending Accounts and logs per-record failures instead of aborting the whole chunk:
public class AccountStatusBatch implements Database.Batchable<SObject>, Database.Stateful {
public Integer failureCount = 0;
public Database.QueryLocator start(Database.BatchableContext bc) {
return Database.getQueryLocator([
SELECT Id, Status__c FROM Account WHERE Status__c = 'Pending'
]);
}
public void execute(Database.BatchableContext bc, List<Account> scope) {
for (Account a : scope) {
a.Status__c = 'Processed';
}
Database.SaveResult[] results = Database.update(scope, false);
for (Database.SaveResult r : results) {
if (!r.isSuccess()) {
failureCount++;
// Optionally insert into a custom Error_Log__c object here
}
}
}
public void finish(Database.BatchableContext bc) {
AsyncApexJob job = [SELECT Status, NumberOfErrors FROM AsyncApexJob WHERE Id = :bc.getJobId()];
System.debug('Job ' + job.Status + ', chunk errors: ' + job.NumberOfErrors
+ ', record failures: ' + failureCount);
}
}Launch it from Anonymous Apex in the Developer Console (requires Author Apex permission):
Id jobId = Database.executeBatch(new AccountStatusBatch(), 200);The returned ID is an AsyncApexJob record. Check progress with SELECT Status, JobItemsProcessed, TotalJobItems FROM AsyncApexJob WHERE Id = :jobId, or in Setup under Apex Jobs. Nothing here is destructive until execute() commits, so a failed launch attempt is harmless — but a running job that already processed chunks cannot be rolled back automatically; design your field changes so re-running the batch is idempotent.
QueryLocator vs Iterable: the 50-million-row dividing line
The choice in start() determines your ceiling:
| Return type | Row ceiling | Constraint |
|---|---|---|
| Database.QueryLocator | ~50 million | Simple SELECT on one object; no complex subquery filters |
| Iterable<SObject> | 50,000 (standard SOQL limit) | Full flexibility — computed scopes, multi-object logic, API-sourced data |
Use QueryLocator whenever you're iterating one object with straightforward filters. Switch to Iterable when the scope can't be expressed as a single query — for example, records assembled from a callout or an aggregate calculation — and accept the 50k cap.
Batch size is a tuning knob, not a vanity metric
The second argument to Database.executeBatch() sets chunk size: default 200, maximum 2000. Bigger chunks mean fewer execute() invocations and less per-chunk overhead, but each chunk still has one heap (6 MB) and one CPU budget (60s). If your execute logic queries related records or does heavy computation per row, a chunk of 2000 will hit limits long before it finishes. Practical guidance:
- Start at 200. Raise it only after testing with production-like data volumes.
- If chunks fail on heap or CPU, halve the size before rewriting logic.
- For simple field updates with no child queries, 1000–2000 is often safe.
Common mistakes that bite in production
Unhandled exceptions kill the whole chunk. If one record throws and you don't catch it, the entire scope fails. The platform retries the chunk, but repeated failure marks the job Failed. Use Database.update(scope, false) and inspect SaveResult so one bad record costs you one record.
Mixed DML on setup and non-setup objects. Updating a User and an Account in the same transaction throws a mixed-DML error. Split setup-object work into a separate batch or a Queueable.
Stateful bloat. Database.Stateful serializes the entire instance between chunks. Accumulating thousands of failed records in a List member will blow the heap. Log errors to a custom object inside execute() and keep only counters in state.
Queue saturation. An org can hold only 5 batch jobs in the active/queued state, and there's a rolling 24-hour cap on total records processed (check Setup > System Overview for your current numbers). Chaining the next job from finish() is the safe pattern for sequential work; firing many batches in a loop from one transaction risks queuing failures.
Verifying the result
After the job completes, confirm three things: the AsyncApexJob.Status is Completed, NumberOfErrors is 0 (or matches your logged failures), and a spot-check SOQL query confirms records actually changed, e.g. SELECT COUNT() FROM Account WHERE Status__c = 'Pending' should return 0 for the example above. In a debug log for the running user, Limits.getQueryRows() and Limits.getDmlRows() inside execute() show how close each chunk came to its budget — useful before you raise the batch size.
In tests, wrap Database.executeBatch() in Test.startTest()/Test.stopTest(); the job runs synchronously when stopTest() is called, so you can assert on the data immediately. Note that test context executes only the first chunk, so keep test data under one batch size.
When batch is the wrong tool
Batch latency is minutes to hours. If you're synchronizing data to an external system in near-real-time, platform events or Change Data Capture fit better. If the job is small (under ~50k records) but too big for a trigger, Queueable Apex gives you async execution with simpler chaining and no chunking ceremony. Batch earns its complexity specifically when the record count is large, the work is uniform per record, and eventual completion is acceptable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.