Batch Apex Tutorial

Salesforce Batch Apex —
Complete Tutorial with Examples

Learn how to process millions of records safely using the Database.Batchable interface, governor limit strategies, scheduling patterns, and production-ready best practices.

Advertisement

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.
Apex — start() returning a QueryLocator
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.

Apex — execute() method signature
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.

Apex — finish() sending a summary email
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().

Apex — ArchiveStaleLead_Batch.cls
/**
 * 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.

Advertisement

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

Apex — Stateful batch accumulating counters
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.

Apex — executing a batch job from Anonymous Window or trigger
// 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().

Apex — ArchiveStaleLead_Scheduler.cls
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:

Apex — scheduling from Anonymous Window
// 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.

Apex — chaining BatchB from BatchA.finish()
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.

Apex — unit test for a batch class
@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.

Apex — Wrong vs. Correct SOQL in execute()
// ✗ 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.

Advertisement

Practise Batch Apex on ApexArena

Reinforce everything you've learned by solving real Batch Apex coding challenges in your browser — no org required.

Create Free Account to Start Practising

12 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.