9.3 Batch Apex

Key Takeaways

  • Database.Batchable defines start, execute, and finish; start returns a QueryLocator or Iterable that defines the full workset
  • QueryLocator can address very large volumes (far beyond a single synchronous query list); Iterable is for custom iteration when a single SOQL locator is not enough
  • execute runs repeatedly for each scope (batch size); governor limits apply per execute invocation, not once for the whole job
  • Database.Stateful preserves instance variable state across execute calls; without it, non-static instance fields reset each execute
  • Use Batch for large data volumes; use Queueable for lighter deferred work—chain batches from finish and monitor via AsyncApexJob
Last updated: August 2026

9.3 Batch Apex

Quick Answer: Batch Apex implements Database.Batchable with start, execute, and finish. start returns a QueryLocator (preferred for large SOQL sets) or Iterable; the platform calls execute once per scope chunk with fresh governor limits; finish runs cleanup or chaining. Use batch for large data volumes; keep Queueable for smaller post-commit jobs.

When a nightly job must touch hundreds of thousands of records, synchronous Apex and even single Queueable executes hit heap, CPU, or query row walls. Batch Apex is the platform’s built-in chunking engine—and a high-value Platform Developer I topic.

Database.Batchable Anatomy

public class AccountIndustryBatch
    implements Database.Batchable<SObject>, Database.Stateful {

    public Integer recordsProcessed = 0;

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator([
            SELECT Id, Industry, Description
            FROM Account
            WHERE Industry = null
        ]);
    }

    public void execute(Database.BatchableContext bc, List<SObject> scope) {
        List<Account> updates = new List<Account>();
        for (SObject sob : scope) {
            Account a = (Account)sob;
            a.Description = 'Industry review required';
            updates.add(a);
            recordsProcessed++;
        }
        update updates;
    }

    public void finish(Database.BatchableContext bc) {
        System.debug('Processed: ' + recordsProcessed);
        // optional: email admin, chain next batch, enqueue Queueable
    }
}

Launch the job:

Id batchJobId = Database.executeBatch(new AccountIndustryBatch(), 200);

The optional second argument is the scope size (records per execute). Default is often 200 when omitted (confirm behavior in your API version docs; exams treat 200 as the familiar default). Maximum scope is capped by the platform (commonly discussed as up to 2000). Smaller scopes reduce per-execute heap/CPU risk; larger scopes reduce the number of execute rounds but raise per-chunk governor pressure.

start: QueryLocator vs Iterable

Database.QueryLocator

public Database.QueryLocator start(Database.BatchableContext bc) {
    return Database.getQueryLocator(
        'SELECT Id, Name FROM Contact WHERE Email = null'
    );
}

Why QueryLocator wins for scale:

  • Designed for very large result sets (on the order of tens of millions of rows in batch context—far beyond normal synchronous query list patterns).
  • The platform manages cursor-like retrieval into scopes.
  • Ideal when a single SOQL statement defines the entire population.

Iterable

public Iterable<Id> start(Database.BatchableContext bc) {
    // Custom list of Ids from complex pre-logic, callouts, or multiple sources
    return someIdList;
}

Use Iterable when:

  • Work items are not a single straightforward SOQL locator (custom iteration, mixed sources).
  • You need to batch over non-sObject types (for example, Iterable<String> of external keys) with a matching Batchable<String> style generic.

Iterable batches still chunk into scopes, but they do not replace QueryLocator’s massive SOQL volume advantages when pure SOQL defines the set. Exam: if the stem says “millions of Account records matching a filter,” answer QueryLocator.

execute: Scope Size and Per-Chunk Governors

Each execute invocation receives a List of at most scope records and runs as its own transaction-like unit with governor limits reset for that execute (a critical exam idea).

Implications:

TopicWhat to remember
SOQL/DML in executeLimits apply per execute, so bulk patterns still required inside the chunk
Failure isolationOne scope can fail without necessarily undoing other completed scopes (design for partial progress)
Trigger re-entryUpdates in execute fire triggers—bulk-safe triggers remain mandatory
HeapScope size is your primary dial when execute hits heap
public void execute(Database.BatchableContext bc, List<Contact> scope) {
    // Still bulkify inside the scope!
    Set<Id> accountIds = new Set<Id>();
    for (Contact c : scope) {
        if (c.AccountId != null) accountIds.add(c.AccountId);
    }
    Map<Id, Account> accounts = new Map<Id, Account>([
        SELECT Id, Name FROM Account WHERE Id IN :accountIds
    ]);
    // ... use map, then one DML ...
}

Anti-pattern: SOQL inside a per-record loop within execute. You only have 200 (or N) records, but you can still blow SOQL limits if you query per row.

Database.Stateful

By default, instance variables on the batch class do not retain changes across execute calls the way beginners expect—each execute may work with a fresh deserialized state unless you implement Database.Stateful.

public class CountingBatch implements Database.Batchable<SObject>, Database.Stateful {
    public Integer total = 0;

    public Database.QueryLocator start(Database.BatchableContext bc) {
        return Database.getQueryLocator('SELECT Id FROM Lead WHERE IsConverted = false');
    }

    public void execute(Database.BatchableContext bc, List<SObject> scope) {
        total += scope.size(); // preserved across executes because Stateful
    }

    public void finish(Database.BatchableContext bc) {
        System.debug('Total leads in job: ' + total);
    }
}

Use Stateful for: running totals, collected error Id lists (within reason), flags, small aggregate metrics for finish emails.

Avoid Stateful for: huge collections that grow every execute—state is serialized between chunks and can hit heap/async limits. Prefer writing progress to custom objects if you must track massive intermediate data.

Static variables are not a substitute for Stateful across batch executes in the way candidates hope; rely on Stateful instance fields or durable storage.

Callouts in Batch

Implement Database.AllowsCallouts:

public class SyncBatch implements Database.Batchable<SObject>, Database.AllowsCallouts {
    // start / execute with Http callouts per scope / finish
}

Callout limits still apply per execute. If each record needs a callout, choose a small scope size (sometimes 1–10) so you stay under callout counts per transaction. That is a classic design tradeoff on scenario questions.

Chaining Batches and finish()

finish runs once after all executes complete (or when the job ends). Common uses:

  • Send a summary email or platform event
  • Launch a second batch for the next object domain
  • System.enqueueJob for a lightweight post-step
  • Update a custom “job run” record with status
public void finish(Database.BatchableContext bc) {
    Database.executeBatch(new RelatedOpportunityBatch(), 200);
}

Chaining from finish keeps large multi-object migrations ordered: Accounts → Contacts → Opportunities, each as its own batch job.

Monitoring with AsyncApexJob

AsyncApexJob job = [
    SELECT Id, Status, JobType, NumberOfErrors,
           JobItemsProcessed, TotalJobItems, CreatedDate, CompletedDate
    FROM AsyncApexJob
    WHERE Id = :batchJobId
];

Admins also monitor under Setup → Apex Jobs. Status values include Queued, Holding, Preparing, Processing, Completed, Failed, Aborted. JobItemsProcessed / TotalJobItems reflect batch chunks, useful for progress UIs and support.

Database.BatchableContext provides getJobId() inside start/execute/finish for the same correlation.

Batch vs Queueable for Large Data Volumes

SituationPrefer
Hundreds of thousands / millions of rows, SOQL-defined setBatch + QueryLocator
Post-trigger callout for ≤ hundreds of recordsQueueable
Need flexible chaining of small steps with rich stateQueueable
Need scheduled nightly sweep of an objectSchedulable → Batch
Must tune callouts per limited transactionBatch with small scope
Simple deferred Id processingQueueable or @future

Rule of thumb taught on the exam: Batch is for volume and chunking; Queueable is for flexible async units of work. Using Queueable in a loop to fake batching is an anti-pattern when Batchable exists.

Testing Batch Apex

@IsTest
static void batchUpdatesAccounts() {
    List<Account> data = new List<Account>();
    for (Integer i = 0; i < 50; i++) {
        data.add(new Account(Name = 'Batch ' + i));
    }
    insert data;

    Test.startTest();
    Database.executeBatch(new AccountIndustryBatch(), 50);
    Test.stopTest();

    // assert post-batch field values
}

Test.stopTest() forces the batch to run to completion in tests (start, all executes, finish). Keep test data volumes modest but non-trivial; you do not need millions of rows in unit tests.

Exam Checklist

  1. Name the three methods: start / execute / finish.
  2. Choose QueryLocator for large SOQL sets; Iterable for custom sets.
  3. Know scope size trades throughput vs per-execute limits.
  4. Stateful for cross-execute instance totals.
  5. AllowsCallouts + small scope when calling out per record.
  6. Governors are per execute.
  7. Monitor with AsyncApexJob; chain from finish.
  8. Prefer batch over queueable when the problem is scale, not just “run later.”

Batch Apex is how Salesforce apps process org-scale data safely—chunk by chunk, limit by limit, job by job.

Test Your Knowledge

Why do developers often return a Database.QueryLocator from Batchable start when processing a very large set of Account records?

A
B
C
D
Test Your Knowledge

What is the primary effect of implementing Database.Stateful on a batch class?

A
B
C
D
Test Your Knowledge

A batch execute method performs one HTTP callout per record. The job fails with callout limit errors. What is the best first design adjustment?

A
B
C
D