---
title: "Writing a Batch Apex job that survives production"
url: "https://trailassembly.com/apex-batch-that-scales/"
topic: "Apex and Development"
published: "2026-09-11T04:11:53+00:00"
updated: "2026-10-06T13:52:34+00:00"
---

# Writing a Batch Apex job that survives production

**Briefly:** the start method returns a QueryLocator, not a list, or you are capped at 50,000 records before you begin. Keep state out of the class unless you implement `Database.Stateful`, size the scope for the heaviest record rather than the average, and write the failure path first — because a batch that dies at chunk 900 of 1,000 with no record of what it did is worse than one that never ran.

## The three methods, and what each is really for

**start** decides the population. Return `Database.getQueryLocator` and you can address up to 50 million records. Return a list and you are limited to the ordinary 50,000-row query limit. That single choice is the difference between a batch that scales and one that quietly cannot.

**execute** runs once per chunk, and each run is its own transaction with its own [governor limits](https://trailassembly.com/governor-limits-reference/). This is the entire point of batch: not a bigger allowance, a fresh one per chunk.

**finish** runs once at the end, in its own transaction. It is where the summary email, the chained job and the status update belong, and it runs whether the chunks succeeded or not, which matters for what you put in it.

## Scope size: the number people set once and never revisit

The default is 200 and the maximum for a QueryLocator is 2,000. Smaller scopes mean more transactions and more overhead. Larger scopes mean fewer transactions, each closer to its limits.

Size it for the heaviest record, not the typical one. If one account has 5,000 child records and the rest have 5, the chunk containing that account is the one that fails, and it fails at whatever scope you set, because chunks are not balanced by weight.

A practical approach: start at 200, run against production-shaped data, and look at the highest CPU and heap use across chunks rather than the average. If the worst chunk is at 70% of any limit, drop the scope. Averages hide exactly the chunk that will break.

## State does not survive between chunks, and that surprises people

Each execute is a separate transaction, so member variables reset unless the class implements `Database.Stateful`:

```apex
public class RecalculateBatch implements Database.Batchable<SObject>, Database.Stateful {
    public Integer processed = 0;
    public Integer failed = 0;
    // these now survive from chunk to chunk
}
```

Keep what you carry small. Stateful serialises the instance between chunks, so a Map that grows with every record turns into a heap problem three quarters of the way through a long run, which is a particularly annoying way to lose four hours of processing.

Counters and totals: yes. Accumulated collections of records: almost never.

## Write the failure path before the happy path

A chunk that throws is rolled back on its own, and the job carries on. That is usually what you want, and it means a completed job can hide hundreds of failed records unless you record them.

Three things worth building in from the start:

1. **Catch per chunk, not per job.** Wrap the work in the execute method so one bad record does not lose the other 199, and use `Database.update(records, false)` when partial success is acceptable — then read the results rather than assuming.

2. **Write failures somewhere durable.** A custom object row per failure, with the record Id and the error. Debug logs are gone in days and are not queryable when someone asks what happened.

3. **Report in finish.** Query `AsyncApexJob` for the job’s own numbers and put them somewhere a human sees. A batch nobody monitors is a batch nobody knows has been failing.

## Chaining, and the trap in it

Calling `Database.executeBatch` from the finish method chains the next job, and it is the normal way to sequence work. The trap is that a chain of batches each processing the output of the last will happily run for hours and, if step three fails, leave the data half-migrated with no obvious restart point.

Design each link to be re-runnable. If the batch can be run twice without doubling anything (because it filters on a status field, or upserts on a key) a failed chain becomes an inconvenience rather than a recovery exercise. That is the same property that makes an integration safe, and we went through it in [design the retry before you write the callout](https://trailassembly.com/callout-retries-and-idempotency/).

## When batch is the wrong tool

**Small volumes.** Below a few thousand records a [queueable](https://trailassembly.com/apex-choosing-async/) is simpler, starts sooner and is easier to test. Batch adds queue latency and monitoring surface for nothing.

**Anything time-sensitive.** Batch jobs queue behind other async work, and the org’s concurrent batch slots are limited. If the requirement mentions “immediately”, this is not the mechanism.

**Work whose cost is per record and small.** If the real problem is a query inside a loop, batch just spreads the same inefficiency across more transactions. Fix the [query pattern](https://trailassembly.com/soql-101/) first.

## Frequently asked

**How many batch jobs can run at once?** Five active or queued at a time, org-wide, with the rest held in the flex queue. That number is why a batch triggered by user action is a bad design.

**Can I test a batch properly?** Yes, and the test only runs one chunk between `Test.startTest` and `Test.stopTest`. So a test proves the logic, not the chunking — for that you need a real run against volume.

**Does the scope size affect the query row limit?** The QueryLocator streams, so the 50,000-row query limit does not apply to the population. It applies to queries you make inside execute, per chunk.

**What about Batch Apex vs a Queueable chain for large volumes?** Batch, when the population is a query. A chain gives finer control and requires you to build the chunking, the resumption and the reporting that batch already has.

---

Source: https://trailassembly.com/apex-batch-that-scales/
Trail Assembly — independent Salesforce write-ups.
