ApexSensei

Blog

Batch Apex: Process Large Salesforce Jobs

By Firus Hanov · · 9 min read

Batch Apex for large Salesforce jobs: when to use it vs Queueable, a correct start/execute/finish example, official limits, and common mistakes.

Batch Apex: Process Large Salesforce Jobs

Batch Apex is how you walk a large Salesforce data set without putting every row in one transaction. You implement Database.Batchable, return a locator or iterable from start, and Salesforce calls execute once per scope. finish runs at the end.

Each execute is a new transaction. That is the point. Governor limits reset for every scope. The official guide is Use Batch Apex in the Apex Developer Guide.

When Batch Apex is the right tool

Use Batch when the job is "every Account that matches this query" and that set can be tens of thousands of rows, or more. A QueryLocator in start can return up to 50 million records. A normal SOQL query cannot.

Use Queueable Apex when you have a known list, a callout, or a short follow-up after a trigger. Queueable is one extra transaction. It is not a scan of the org.

Use @future only for a small, fire-and-forget hop with Ids or primitives. Do not try to turn a future method into a batch.

Use Schedulable to start the batch on a clock. The scheduled class should be thin: Database.executeBatch(new MyBatch(), 200); and return. Scheduled Apex still uses the synchronous governor limits in that first transaction.

A short, correct example

This job finds Accounts with a blank Description and writes a stamp. start returns a QueryLocator. execute updates only the scope it received. finish is left for logging or a follow-up Queueable.

public class AccountDescriptionBatch implements Database.Batchable<SObject> {
    public Database.QueryLocator start(Database.BatchableContext context) {
        return Database.getQueryLocator([
            SELECT Id, Description
            FROM Account
            WHERE Description = null
        ]);
    }

    public void execute(Database.BatchableContext context, List<SObject> scope) {
        List<Account> rows = new List<Account>();
        for (SObject raw : scope) {
            Account row = (Account) raw;
            row.Description = 'Batched ' + Date.today();
            rows.add(row);
        }
        update rows;
    }

    public void finish(Database.BatchableContext context) {
        System.debug('AccountDescriptionBatch finished ' + context.getJobId());
    }
}

Id jobId = Database.executeBatch(new AccountDescriptionBatch(), 200);

The second argument is the scope size. Salesforce's default is 200. The documented maximum is 2,000. Smaller scopes stay safer when each row does more work. Larger scopes mean fewer execute calls and fewer hits on the daily async cap.

The matching drills are Build a batch lifecycle and Count records across batch scopes.

Limits that actually bite

Figures below are from the Batch Apex guide and the Execution Governors and Limits table.

  • Up to 5 batch jobs can be queued or active at once in the org.
  • The Apex flex queue can hold 100 additional jobs in Holding. Database.executeBatch throws a LimitException when that queue is full.
  • A QueryLocator in start can return up to 50 million records. Using a QueryLocator bypasses the normal 50,000-row SOQL retrieval cap for that locator. An iterable does not get that bypass.
  • In a regular (non-batch) transaction, Database.getQueryLocator still caps at 10,000 rows. The 50 million figure is a batch start privilege, not a general one.
  • Each execute gets a fresh async limit set: 200 SOQL queries, 150 DML statements, 10,000 DML rows, 12 MB heap, 60,000 ms CPU.
  • From an execute method you may System.enqueueJob only once. Do not fan out a Queueable per record.
  • Start, execute, and finish each count against the shared daily asynchronous Apex executions. Salesforce documents that org-wide cap as 250,000, or user licenses times 200, whichever is greater. Tests in a running test method may submit at most 5 batch jobs.

If you need state across scopes, implement Database.Stateful and keep a small counter on the instance. Do not stuff a huge list into that instance. That is how heap dies on scope 40. The debug drill Fix a batch that forgets its count is about that state.

Common mistakes

Querying the world in execute. start already chose the rows. execute should work on scope. A wide unconstrained query in every scope burns SOQL and heap for no reason.

One DML per record in the scope. A scope of 200 with update row; inside the loop is 200 DML statements. The async cap is still 150. Collect, then write once. Same habit as a trigger. See Fix DML inside a loop.

Scope size 2,000 with a heavy per-row callout. Callouts are 100 per transaction. A fat scope plus a callout per row fails in the first execute. Shrink the scope or move the callout to a Queueable that implements Database.AllowsCallouts.

Starting a batch from a batch without a plan. You can enqueue one Queueable from execute, or start another batch from finish. Chaining every scope into a new batch will fill the five active slots and then the flex queue.

Using Batch for ten records. That job waits behind five others and a flex queue. A Queueable would have been enough.

How this compares, in one line each

  • Batch — QueryLocator, many transactions, five active jobs, flex queue of 100.
  • Queueable — one follow-up transaction, job Id, one child, 50 enqueues from a sync caller.
  • @future — static method, primitives or Ids, no job Id in your code.
  • Schedulable — starts other work at a time. Does not replace Batch or Queueable.

The shared per-transaction numbers live in Salesforce Governor Limits: Sync vs Async. If you are choosing a first async drill, start with a future method, then the Queueable, then this batch pair.

Practice Apex free on ApexSensei