Managing Apex DML Limits with Safe Batch Processing
Learn how to stay within Apex DML and heap limits by batching records, using savepoints, and choosing between Batch Apex and Queueable for large data jobs.
29 Dec 2025, 10:55 UTC

The problem: hitting Apex limits on large data jobs
When a trigger or anonymous Apex block tries to process more than a few thousand records, you quickly run into governor limits: 150 DML statements, 500 000 total DML rows, and a 6 MB heap ceiling. Exceeding any of these throws a limit exception that rolls back the whole transaction, leaving you with no data processed and a frustrating timeout.
Thesis: batch records, protect heap, and choose the right async pattern
By breaking the work into chunks of ≤ 1 000 rows, using List sub‑lists for each DML call, and guarding heap usage with savepoints, you can stay safely under the limits. For jobs that need more than a few minutes, move the batching logic into a Queueable or Batch Apex class.
Understanding the hard limits
- DML statements: 150 per transaction
- Total DML rows: 500 000 per transaction
- Heap size: 6 MB (non‑reentrant)
- Synchronous timeout: 10 seconds for anonymous Apex, 12 seconds for triggers
These numbers are documented in the Limits class and are enforced per execution context.
Batching with List sub‑lists
The simplest way to respect the DML‑statement cap is to collect records into a master list and then split it into sub‑lists of a safe size. A common choice is 200 records per DML call, which leaves plenty of room for other statements in the same transaction.
public with sharing class AccountBatchUpdater {
public static void updateInBatches(List<Account> accounts, Integer batchSize) {
Integer processed = 0;
while (processed < accounts.size()) {
List<Account> batch = accounts.subList(
processed,
Math.min(processed + batchSize, accounts.size())
);
// Optional: set a savepoint so we can roll back only this batch on failure
Savepoint sp = Database.setSavepoint();
try {
Database.update(batch, false); // allow partial success
processed += batch.size();
} catch (DmlException e) {
// Roll back the batch and continue or abort as needed
Database.rollback(sp);
// Log or re‑throw depending on business rule
throw e;
}
}
}
}Key points:
- The
subListcall creates a view, not a copy, so heap impact stays low. - Setting a savepoint before each DML lets you undo just that batch if an error occurs, preserving any earlier successful batches.
- Using
Database.update(..., false)returns aDatabase.SaveResult[]array you can inspect for partial successes.
Choosing an async container
If the total record count exceeds what can be comfortably processed in a single synchronous transaction (e.g., > 10 000 rows or processing that takes > 8 seconds), move the batching logic into a Queueable or Batch Apex class.
Queueable example
public class AccountUpdateQueueable implements Queueable {
private List<Account> accounts;
private Integer batchSize;
public AccountUpdateQueueable(List<Account> accs, Integer size) {
this.accounts = accs;
this.batchSize = size;
}
public void execute(QueueableContext ctx) {
AccountBatchUpdater.updateInBatches(accounts, batchSize);
// Optionally chain another Queueable for the next chunk
}
}To start the job from anonymous Apex or a trigger:
List<Account> toUpdate = [SELECT Id, Industry FROM Account WHERE Industry = NULL LIMIT 5000];
System.enqueue(new AccountUpdateQueueable(toUpdate, 200));Batch Apex would replace the while‑loop with the start, execute, and finish methods, automatically governing the batch size via the constructor argument.
Worked example: updating 3 500 Accounts
Suppose you need to set a default industry on all Accounts that currently have a null value. The following steps illustrate a safe approach:
- Query the target records (respecting the 50 000‑row query limit).
- Pass the list to
AccountBatchUpdater.updateInBatcheswith a batch size of 200. - Each iteration:
- Creates a savepoint.
- Attempts a
Database.updateon 200 rows. - If any row fails, the savepoint rolls back only that 200‑row chunk.
- Successful rows are committed implicitly when the method exits.
- After the loop finishes, you can check the Apex log for:
- DML statements count (should be ≤ 18 for 3 500 rows @ 200 each).
- Heap usage staying well below 6 MB.
- No
LimitExceptionentries.
If you notice the heap creeping upward, reduce the batch size or clear unnecessary collections after each iteration.
Trade‑off and limitation
The biggest trade‑off is atomicity. By allowing partial success (Database.update(..., false)) and rolling back only the failing batch, you gain resilience but lose the guarantee that either all records succeed or none do. If your business rule requires an all‑or‑nothing outcome, you must either:
- Pre‑validate data to eliminate expected failures, or
- Process the entire set in a single transaction and accept the risk of hitting limits.
Another limitation is the 10 000‑row limit on subList iterations when you chain Queueables: each Queueable execution counts toward the asynchronous job limits (250 000 executions per 24 hours). Monitor those limits in Setup → Monitor → Apex Jobs.
Actionable closing
To implement this pattern today:
- Decide on a safe batch size (200–400 works for most objects).
- Wrap each DML call in a try/finally with a savepoint if you need rollback on failure.
- Move the loop into a Queueable or Batch Apex when the record count exceeds synchronous limits.
- After running, inspect the Debug Logs for DML rows, heap size, and any limit exceptions.
By respecting the per‑transaction caps and handling errors at the batch level, you can process hundreds of thousands of records without hitting the dreaded TooManyDMLRows or HeapSizeLimitExceeded exceptions.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.