1 What is Batch Apex?
Batch Apex is Salesforce's mechanism for processing large volumes of records asynchronously — records that would exceed the synchronous governor limits if processed all at once. Instead of handling 50,000 records in a single transaction (which Salesforce disallows), Batch Apex slices your data into configurable chunks called batches and processes each chunk in its own isolated transaction, each with a fresh set of governor limits.
When Should You Use Batch Apex?
Batch Apex is the right tool whenever you need to:
- Process more than 50,000 records — synchronous Apex caps DML rows at 10,000 and SOQL rows at 50,000 per transaction; Batch Apex processes millions across many transactions.
- Run nightly data cleanup jobs — archiving stale records, resetting fields, or re-parenting orphaned records on a schedule.
- Mass data migrations — backfilling a new field across every Account or Contact after a schema change.
- Complex recalculations — recomputing aggregated roll-up values or syncing Salesforce data to an external system record-by-record.
- Send bulk notifications — triggering emails or platform events for every record that meets a condition, without hitting the single-transaction email limit.
Governor Limits Batch Apex Bypasses
Each execute() call in a batch job is a separate, isolated Apex transaction. This means every chunk of records starts with a completely fresh governor limit budget:
- Up to 10,000 DML rows per execute() — if your scope is 200 records and each triggers one DML row, you use only 200 of your 10,000 budget.
- Up to 100 SOQL queries per execute() — queries in execute() do not accumulate across chunks.
- Up to 50,000 SOQL rows returned per execute() — the batch framework resets this per chunk.
- Up to 100 callouts per execute() (with Database.AllowsCallouts).
Key insight: The governor limits that matter for batch are per-execute() limits, not per-batch-job limits. A batch job touching 1,000,000 records with scope 200 runs 5,000 execute() calls — each with its own full limit budget. The total CPU time limit of 60 seconds per transaction still applies to each execute() call individually.
2 Database.Batchable Interface — start(), execute(), finish()
Every batch class must implement the Database.Batchable<SObject> interface, which requires you to define exactly three methods. Salesforce's batch framework calls them in order, once per batch job.
start(Database.BatchableContext bc)
Called once at the beginning of the job. Its job is to identify and return the full set of records to be processed. It has two return type options:
- Database.QueryLocator — the standard choice. Wraps a SOQL query and can return up to 50 million records. Salesforce handles cursor-based pagination internally. Use this for almost all batch jobs.
- Iterable<SObject> — lets you return a custom collection (e.g., built by calling a web service or applying complex in-memory filtering). Limited to 50,000 records because the entire collection is held in memory. Use this only when SOQL cannot express your selection logic.
global Database.QueryLocator start(Database.BatchableContext bc) { // Returning a QueryLocator — can handle up to 50 million records. // The SOQL string is evaluated lazily as batches are processed. return Database.getQueryLocator( 'SELECT Id, Status, LastModifiedDate, OwnerId FROM Lead ' + 'WHERE Status = \'Open\' AND LastModifiedDate < LAST_N_DAYS:365' ); }
execute(Database.BatchableContext bc, List<SObject> scope)
Called once for each chunk (batch) of records. The scope parameter contains the current chunk — by default, up to 200 records. This is where your business logic lives: querying child records, performing DML, sending emails, or making callouts. Every execute() invocation is a completely separate transaction with fresh governor limits.
global void execute(Database.BatchableContext bc, List<Lead> scope) { // 'scope' holds up to 200 Lead records per call (configurable). // Process them here — DML, SOQL queries, and callouts all reset here. List<Lead> toUpdate = new List<Lead>(); for (Lead l : scope) { l.Status = 'Archived'; toUpdate.add(l); } if (!toUpdate.isEmpty()) { update toUpdate; } }
finish(Database.BatchableContext bc)
Called once after all execute() invocations have completed. Ideal for sending a summary email, posting a Chatter message, triggering a follow-on process, or chaining another batch job. The BatchableContext parameter exposes the AsyncApexJob ID so you can query final job statistics.
global void finish(Database.BatchableContext bc) { // Query the AsyncApexJob to report on results. AsyncApexJob job = [ SELECT Id, Status, NumberOfErrors, JobItemsProcessed, TotalJobItems FROM AsyncApexJob WHERE Id = :bc.getJobId() ]; Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); mail.setToAddresses(new String[] { 'admin@example.com' }); mail.setSubject('Lead Archive Batch Completed'); mail.setPlainTextBody( 'Status: ' + job.Status + '\n' + 'Batches processed: ' + job.JobItemsProcessed + ' of ' + job.TotalJobItems + '\n' + 'Errors: ' + job.NumberOfErrors ); Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail }); }
3 Complete Batch Class — Archive Stale Leads
The following is a production-ready batch class that archives Leads that have been in an "Open" status for more than 365 days. It demonstrates all three required methods, proper error handling with Database.update() partial success, and a summary email in finish().
/** * ArchiveStaleLead_Batch * Finds all Open Leads not modified in the last 365 days and marks them Archived. * Run: Database.executeBatch(new ArchiveStaleLead_Batch(), 200); */ global class ArchiveStaleLead_Batch implements Database.Batchable<SObject> { // Track how many records were successfully updated. global Integer successCount = 0; global Integer errorCount = 0; // ── 1. start() ───────────────────────────────────────────────────── global Database.QueryLocator start(Database.BatchableContext bc) { return Database.getQueryLocator([ SELECT Id, FirstName, LastName, Status, LastModifiedDate FROM Lead WHERE Status = 'Open' AND LastModifiedDate < LAST_N_DAYS:365 AND IsConverted = false ]); } // ── 2. execute() ──────────────────────────────────────────────────── global void execute(Database.BatchableContext bc, List<Lead> scope) { for (Lead l : scope) { l.Status = 'Archived'; } // Use Database.update with allOrNone=false so one bad record // doesn't roll back the whole batch chunk. Database.SaveResult[] results = Database.update(scope, false); for (Database.SaveResult sr : results) { if (sr.isSuccess()) { successCount++; } else { errorCount++; // Log error for each failed record. for (Database.Error err : sr.getErrors()) { System.debug(LoggingLevel.ERROR, 'Lead update failed: ' + err.getMessage() + ' Fields: ' + err.getFields()); } } } } // ── 3. finish() ───────────────────────────────────────────────────── global void finish(Database.BatchableContext bc) { AsyncApexJob job = [ SELECT Id, Status, NumberOfErrors, JobItemsProcessed, TotalJobItems FROM AsyncApexJob WHERE Id = :bc.getJobId() ]; Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage(); mail.setToAddresses(new List<String> { 'admin@yourorg.com' }); mail.setSubject('[ApexArena] Lead Archive Batch — ' + job.Status); mail.setPlainTextBody( 'Job ID: ' + job.Id + '\n' + 'Final status: ' + job.Status + '\n' + 'Batches run: ' + job.JobItemsProcessed + ' / ' + job.TotalJobItems + '\n' + 'Batch errors: ' + job.NumberOfErrors + '\n' + 'Records OK: ' + successCount + '\n' + 'Records failed: ' + errorCount ); Messaging.sendEmail( new List<Messaging.SingleEmailMessage> { mail } ); } }
Note on Database.update(scope, false): Passing false as the second argument enables partial success — records that fail validation rules or triggers are skipped and reported in the SaveResult, but the rest of the chunk commits successfully. This is strongly recommended for batch jobs where a single bad record should not abort processing of the other 199.
4 Database.Stateful — Preserving State Across Chunks
By default, each execute() call is treated as a fresh instantiation of your batch class. Instance variables are reset to their default values between chunks. This means you cannot simply declare a counter and expect it to accumulate across execute() calls — unless you implement Database.Stateful.
How Database.Stateful Works
When your class implements both Database.Batchable and Database.Stateful, Salesforce serialises the instance's field values after each execute() call and deserialises them back before the next one. The accumulated values therefore survive across all chunk boundaries, all the way to finish().
Performance cost: Serialising and deserialising the class state between execute() calls adds overhead. Keep your stateful fields lean — use primitive types and small collections. Do not store entire lists of SObject records as instance variables; you will quickly exhaust heap limits.
Counter Example with Database.Stateful
global class ContactScoreRecalc_Batch implements Database.Batchable<SObject>, Database.Stateful { // These fields persist across ALL execute() calls. global Integer totalProcessed = 0; global Integer totalUpdated = 0; global Integer totalSkipped = 0; global List<String> failedIds = new List<String>(); global Database.QueryLocator start(Database.BatchableContext bc) { return Database.getQueryLocator([ SELECT Id, Email, Phone, MailingStreet, Engagement_Score__c FROM Contact WHERE IsDeleted = false ]); } global void execute(Database.BatchableContext bc, List<Contact> scope) { List<Contact> toUpdate = new List<Contact>(); for (Contact c : scope) { totalProcessed++; Integer score = 0; if (String.isNotBlank(c.Email)) score += 30; if (String.isNotBlank(c.Phone)) score += 20; if (String.isNotBlank(c.MailingStreet)) score += 10; if (score != c.Engagement_Score__c) { c.Engagement_Score__c = score; toUpdate.add(c); totalUpdated++; } else { totalSkipped++; } } Database.SaveResult[] results = Database.update(toUpdate, false); for (Integer i = 0; i < results.size(); i++) { if (!results[i].isSuccess()) { failedIds.add(toUpdate[i].Id); // accumulated across chunks } } } global void finish(Database.BatchableContext bc) { System.debug('Total processed: ' + totalProcessed); System.debug('Total updated: ' + totalUpdated); System.debug('Total skipped: ' + totalSkipped); System.debug('Failed IDs: ' + failedIds); } }
Without Database.Stateful, totalProcessed would always read 0 at the start of every execute() call. With it, execute() call #5,000 can see the running total from the previous 4,999 calls — making it possible to produce a meaningful summary in finish().
5 Scheduling a Batch — Database.executeBatch() and Scope Size
Executing a Batch Job
You start a batch job by calling Database.executeBatch(). It accepts one or two arguments:
Database.executeBatch(batchInstance)— uses the default scope of 200 records per chunk.Database.executeBatch(batchInstance, scopeSize)— overrides the chunk size (1 to 2,000).
The method returns an Id — the AsyncApexJob ID — which you can use to query job status in the AsyncApexJob object.
// Default scope of 200 Id jobId = Database.executeBatch(new ArchiveStaleLead_Batch()); // Explicit scope — use 50 if each record triggers many child queries Id jobId = Database.executeBatch(new ArchiveStaleLead_Batch(), 50); // Check status after submission AsyncApexJob job = [ SELECT Id, Status, NumberOfErrors, JobItemsProcessed, TotalJobItems FROM AsyncApexJob WHERE Id = :jobId ]; System.debug('Job Status: ' + job.Status);
Choosing the Right Scope Size
The scope size (batch size) is one of the most important tuning parameters in batch Apex. There is no universally correct value — it depends on what you are doing inside execute():
- 200 (default) — good starting point for simple field updates with no related-object queries.
- 50–100 — when execute() fires complex triggers, queries child records, or performs multiple DML statements per record. Smaller batches reduce the risk of hitting CPU time or heap limits inside a single execute() call.
- 2,000 (maximum) — only appropriate for read-only jobs (e.g., aggregating data into custom objects) where DML and query counts per record are minimal. Never use 2,000 if your logic runs triggers or queries related records.
Rule of thumb: Start with 200. If execute() consistently hits governor limits, halve the scope size. If the job is trivially simple and time matters, double it — up to 2,000.
6 Schedulable + Batch Combination Pattern
It is a very common Salesforce pattern to schedule a batch job to run on a recurring basis (nightly, weekly, etc.). You do this by creating a Schedulable class whose execute() method simply fires the batch. This gives you full cron-based scheduling through the Salesforce UI or System.schedule().
global class ArchiveStaleLead_Scheduler implements Schedulable { /** * Schedule this to run nightly at 2 AM: * System.schedule( * 'Archive Stale Leads - Nightly', * '0 0 2 * * ?', * new ArchiveStaleLead_Scheduler() * ); */ global void execute(SchedulableContext sc) { ArchiveStaleLead_Batch batch = new ArchiveStaleLead_Batch(); Database.executeBatch(batch, 200); } }
To schedule it programmatically from Anonymous Apex:
// Cron expression: seconds minutes hours day-of-month month day-of-week year // '0 0 2 * * ?' means: every day at 02:00 AM String cronExpr = '0 0 2 * * ?'; String jobName = 'Archive Stale Leads — Nightly'; Id scheduleId = System.schedule( jobName, cronExpr, new ArchiveStaleLead_Scheduler() );
You can also schedule directly through Setup → Apex Classes → Schedule Apex, where Salesforce provides a UI to set frequency without writing a cron expression manually.
7 Chaining Batch Jobs
Sometimes you need to run a series of batch jobs in sequence — for example, first recalculate a score, then send notifications based on the new score. Calling Database.executeBatch() inside the finish() method of the first batch is the standard pattern for job chaining.
global class RecalculateScore_Batch implements Database.Batchable<SObject> { global Database.QueryLocator start(Database.BatchableContext bc) { return Database.getQueryLocator([SELECT Id FROM Contact]); } global void execute(Database.BatchableContext bc, List<Contact> scope) { // ... recalculate engagement scores ... } global void finish(Database.BatchableContext bc) { // Chain: after scores are updated, kick off the notification batch. Database.executeBatch(new SendNotifications_Batch(), 200); } } global class SendNotifications_Batch implements Database.Batchable<SObject> { global Database.QueryLocator start(Database.BatchableContext bc) { return Database.getQueryLocator([ SELECT Id FROM Contact WHERE Engagement_Score__c > 50 ]); } global void execute(Database.BatchableContext bc, List<Contact> scope) { // ... send email / platform events ... } global void finish(Database.BatchableContext bc) { System.debug('Full pipeline complete.'); } }
Chaining limit: Each call to Database.executeBatch() in finish() counts towards the org's 5-concurrent-job limit. If you build a long chain, monitor the Apex Jobs queue in Setup to ensure earlier jobs complete before you queue too many downstream batches. Avoid calling executeBatch() in a loop inside finish() — Salesforce allows at most one executeBatch() call per finish().
8 Governor Limits in Batch Apex
Understanding which limits apply per execute() vs. per job is critical to writing correct batch classes.
| Limit | Per execute() call | Notes |
|---|---|---|
| SOQL queries | 100 | Resets each execute() call. Queries in start() and finish() are separate transactions. |
| SOQL rows returned | 50,000 | Across all SOQL statements in a single execute() invocation. |
| DML statements | 150 | Each insert/update/delete/upsert counts as one DML statement regardless of list size. |
| DML rows | 10,000 | Total rows across all DML in one execute() call. |
| CPU time | 60,000 ms | If execute() logic is expensive, reduce scope size rather than increasing time. |
| Heap size | 12 MB | Asynchronous contexts get 12 MB (vs. 6 MB synchronous). Still watch large collection accumulation with Stateful. |
| Callouts | 100 | Requires Database.AllowsCallouts. Cannot mix callout + DML in same transaction without workarounds. |
| Email invocations | 10 | Per execute() call. Prefer sending one summary email in finish() instead of per-chunk emails. |
| Concurrent batch jobs | 5 running simultaneously 100 queued or active | |
| Max QueryLocator rows | 50,000,000 total records across the entire batch job | |
start() and finish() limits: The start() and finish() methods are also separate transactions, each with their own governor limits. start() using a QueryLocator gets the 50-million-row fetch capability, but its other limits (SOQL queries, DML) are the same as any asynchronous transaction.
9 Batch Apex Best Practices
1. Keep the scope size at 200 (the default)
The default scope of 200 records per execute() balances throughput with safety. Only deviate from 200 when you have profiled the job and identified a specific reason — for example, reducing to 50 because execute() fires a complex trigger that queries many child objects per record.
2. Use Database.update(list, false) for partial success
Never use the plain update list; statement inside a batch class. If a single record in your chunk has a validation rule error, the plain DML rolls back all 200 records in that chunk. Use Database.update(scope, false) so good records commit and failed records are captured in SaveResult for logging.
3. Bulkify everything inside execute()
Even though execute() already receives a list, it is easy to accidentally write non-bulkified code — for example, putting a SOQL query inside a for loop over scope. All SOQL and DML inside execute() must be outside loops and operate on collections, exactly as in any other Apex context.
4. Avoid callouts in start() when using QueryLocator
If your batch class's start() method returns a QueryLocator, Salesforce restricts callouts in the start() method because the query cursor is held open. Move any HTTP callouts into execute() and implement Database.AllowsCallouts.
5. Do not fire other batch jobs from execute()
Calling Database.executeBatch() from inside execute() is not permitted and will throw a System.AsyncException at runtime. Chaining must happen exclusively in finish().
6. Minimise stateful data
If you use Database.Stateful, keep instance variables small. Do not accumulate full SObject records — store IDs or primitive counters. Large stateful objects bloat heap consumption during serialisation between batches.
7. Schedule off-peak hours
Heavy batch jobs should run during periods of low user activity — typically 1–5 AM in the org's local time zone. Competing batch jobs slow each other and can push users' interactive requests toward timeout. Use the nightly scheduling pattern shown in Section 6.
8. Test with Test.startTest() / Test.stopTest()
In Apex tests, a batch job runs synchronously between Test.startTest() and Test.stopTest(). Only one execute() call runs in a test (with all scope records). Use System.assertEquals() on AsyncApexJob fields to verify job completion, not intermediate state.
@IsTest private class ArchiveStaleLead_Batch_Test { @TestSetup static void makeData() { List<Lead> leads = new List<Lead>(); for (Integer i = 0; i < 10; i++) { leads.add(new Lead( FirstName = 'Test', LastName = 'Lead ' + i, Company = 'ACME', Status = 'Open' )); } insert leads; } @IsTest static void testBatchArchivesLeads() { Test.startTest(); Id jobId = Database.executeBatch( new ArchiveStaleLead_Batch(), 200 ); Test.stopTest(); // batch runs synchronously here AsyncApexJob job = [ SELECT Status, NumberOfErrors FROM AsyncApexJob WHERE Id = :jobId ]; System.assertEquals('Completed', job.Status, 'Batch job should have completed successfully.'); System.assertEquals(0, job.NumberOfErrors, 'Batch job should have zero errors.'); } }
10 Common Batch Apex Mistakes
Mistake 1 — SOQL inside the execute() loop
Putting a SOQL query inside the for loop that iterates over scope will consume one SOQL query per record. With a scope of 200, you spend 200 of your 100-query budget on the first record... and throw a Too many SOQL queries: 101 exception. Always collect IDs from scope first, then query once outside the loop.
// ✗ WRONG — SOQL inside loop, hits limit at 101 records for (Account acc : scope) { List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :acc.Id]; } // ✓ CORRECT — single SOQL outside the loop Set<Id> accIds = new Map<Id, Account>(scope).keySet(); Map<Id, List<Contact>> contactMap = new Map<Id, List<Contact>>(); for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accIds]) { if (!contactMap.containsKey(c.AccountId)) { contactMap.put(c.AccountId, new List<Contact>()); } contactMap.get(c.AccountId).add(c); }
Mistake 2 — Calling executeBatch() from execute()
Salesforce does not allow you to start a new batch job from inside an execute() call. You will receive a runtime exception: "Database.executeBatch cannot be called from a batch start, batch execute, or future method." All chaining must happen in finish().
Mistake 3 — Assuming instance variables persist without Database.Stateful
A very common beginner mistake is declaring an instance variable (e.g., Integer count = 0;) expecting it to accumulate across all execute() calls. Without Database.Stateful, it always resets to 0 at the start of every chunk. The fix is to add implements Database.Stateful.
Mistake 4 — Setting scope size to 2,000 with complex triggers
A scope size of 2,000 is only safe for very simple, low-cost operations. If each record fires an Apex trigger that issues SOQL queries and DML, a scope of 2,000 will almost certainly hit the CPU time limit (60 seconds per execute()) or the DML rows limit (10,000). Start at 200 and test before increasing.
Mistake 5 — Not handling AllOrNone DML failures
Using plain update scope; means one validation failure in 200 records rolls back all 200. Always use Database.update(scope, false) and iterate through the SaveResult[] to log or re-queue failed records.
Mistake 6 — Querying or modifying in-scope records again inside execute()
The scope list passed to execute() is already in memory. Issuing another SOQL query on the same object with the same Ids wastes a query and risks returning stale data if your query has different WHERE clauses. If you need additional fields, add them to the start() QueryLocator instead.
Practise Batch Apex on ApexArena
Reinforce everything you've learned by solving real Batch Apex coding challenges in your browser — no org required.
Archive Stale Leads Batch
Write a complete batch class that archives Leads not contacted in 180 days.
EasyStateful Error Collector
Accumulate failed record IDs across all execute() calls and report them in finish().
MediumTwo-Stage Data Pipeline
Chain a recalculation batch to a notification batch via finish() and verify sequencing.
MediumScheduled Batch + Cron
Implement a Schedulable wrapper that triggers your batch at 2 AM every weekday.
Hard12 Frequently Asked Questions
The default batch scope size is 200 records per execute() call when you call Database.executeBatch() without specifying a scope parameter. You can specify any value from 1 to 2,000, but 200 is recommended for DML-heavy batches to stay well within governor limits. Only increase it for simple, low-cost operations after performance testing.
Implement the Database.Stateful interface alongside Database.Batchable. Without Database.Stateful, instance variables are reset to their initialisation values between execute() calls. With it, instance variable values persist across all execute() invocations, allowing you to accumulate totals, counters, or error ID lists across every chunk. Keep stateful fields small to avoid heap issues from serialisation.
Salesforce allows up to 5 batch jobs to run simultaneously per org. Up to 100 batch jobs can be queued or active at the same time. If you exceed the queue limit, you receive a LimitException. Use Database.executeBatch() inside the finish() method to chain jobs rather than submitting many jobs simultaneously. Monitor the Apex Jobs page in Setup during heavy batch periods.
Yes, but with restrictions. The batch class must implement the Database.AllowsCallouts marker interface in addition to Database.Batchable. If the start() method returns a QueryLocator, you cannot make callouts in start(). Callouts inside execute() are permitted (up to 100 per transaction), but you cannot perform DML after a callout in the same execute() invocation unless you use a @future(callout=true) method or Platform Events to decouple the DML.
Ready to Master Salesforce Batch Apex?
ApexArena has 300+ hands-on Apex coding challenges — including dedicated Batch Apex, Queueable, and Scheduled Apex tracks. Solve problems in your browser, get instant test feedback, and climb the leaderboard.