Salesforce Dictionary - Free Salesforce GlossarySalesforce Dictionary
All errors
Governor limits

System.LimitException: Too many DML statements: 151

Apex limits a single transaction to 150 DML statements (insert/update/upsert/delete/merge calls). The cap is on the **number of statements**, not the number of records — `update largeList` is 1 statement; `update one;` 200 times is 200 statements.

Also seen asToo many DML statements: 151·Too many DML statements·System.LimitException: Too many DML

You're triaging a batch import that crashes 80% of the way through. The log line says System.LimitException: Too many DML statements: 151, and you can already guess what's in the trigger. The DML count went 1, 2, 3, all the way to 150, and the 151st insert detonated the transaction.

The cap is on statements, not records

The single most important sentence about this error: Apex counts each insert, update, upsert, delete, undelete, or merge keyword (and each corresponding Database.* method) as one statement, regardless of how many records the operation touches. A transaction can run up to 150 of these statements before throwing LimitException.

That phrasing means update bigList where bigList has 9,000 records is one statement. It also means update single; repeated 200 times in a loop is 200 statements, which throws on the 151st call. The two have the same workload but very different governor cost.

The platform's intent is to discourage row-by-row processing. Salesforce's storage and indexes are tuned for bulk operations; a list-based DML lets the platform batch the work efficiently across rows. A loop-based DML defeats that optimization and starves the org of throughput. The 150 cap is the brake.

The classic broken example

Most appearances of this error come from one shape: a DML keyword inside a for loop.

trigger CaseTrigger on Case (before update) {
    for (Case c : Trigger.new) {
        if (c.Status == 'Closed' && c.Resolution__c == null) {
            // Wrong: one update per record. Throws on record #151.
            c.Resolution__c = 'No resolution recorded';
            update c;
        }
    }
}

Three things wrong here, and the error message only flags one of them:

  • The DML is in a loop, so 151 records throws.
  • This is a before update trigger, which doesn't need DML at all (more on that below).
  • Even if it did need DML, a single bulk update is the correct shape.

The platform doesn't tell you which of these mistakes is yours. It just blocks the 151st DML and walks away. You have to read the code to find the loop.

The fix: collect, then DML once

The canonical bulk-safe pattern moves the DML out of the loop. Build a list inside the loop, then do one DML on the list at the bottom.

trigger CaseTrigger on Case (after update) {
    List<Case> toUpdate = new List<Case>();
    for (Case c : Trigger.new) {
        if (c.Status == 'Closed' && c.Resolution__c == null) {
            toUpdate.add(new Case(
                Id = c.Id,
                Resolution__c = 'No resolution recorded'
            ));
        }
    }
    if (!toUpdate.isEmpty()) {
        update toUpdate;
    }
}

Whether the list has 1 record or 10,000 records, this is one DML statement. The 150 cap is no longer in play.

When you don't need DML at all

The pattern above works for after update triggers, where the records have already been written and you want to make a second pass. In a before update trigger, you can mutate Trigger.new directly and the platform persists the mutation as part of the original write. No DML needed.

trigger CaseTrigger on Case (before update) {
    for (Case c : Trigger.new) {
        if (c.Status == 'Closed' && c.Resolution__c == null) {
            c.Resolution__c = 'No resolution recorded';
            // No DML. Mutation is saved with the original insert/update.
        }
    }
}

Before triggers are the cheapest way to enforce a derived field rule. Zero DML, zero CPU spent on a re-save, zero risk of recursion. If your business logic can fit in a before trigger, prefer it.

The fixed example, end to end

A complete service class that respects both the 150-statement cap and the 10,000-row cap:

public class CaseAutoResolutionService {
    /**
     * Stamps a default Resolution__c on closed cases that lack one.
     * Single bulk DML; safe for any batch size.
     */
    public static void stampDefaultResolutions(List<Case> cases) {
        if (cases == null || cases.isEmpty()) return;

        List<Case> toUpdate = new List<Case>();
        for (Case c : cases) {
            if (c.Status == 'Closed' && c.Resolution__c == null) {
                toUpdate.add(new Case(
                    Id = c.Id,
                    Resolution__c = 'No resolution recorded'
                ));
            }
        }
        if (toUpdate.isEmpty()) return;

        // One DML statement; one row tally; safe to retry on partial fail.
        Database.SaveResult[] results = Database.update(toUpdate, false);
        for (Integer i = 0; i < results.size(); i++) {
            if (!results[i].isSuccess()) {
                System.debug(LoggingLevel.ERROR,
                    'Case ' + toUpdate[i].Id + ' failed: ' + results[i].getErrors());
            }
        }
    }
}

Database.update(records, false) is the partial-success variant. It lets some rows fail without rolling back the entire transaction, which matters in bulk contexts where one bad record shouldn't poison the whole batch. The returned SaveResult array carries the per-row outcome.

Triggers that chain to other triggers

A trigger doing DML on records of a different SObject fires that object's trigger. That second trigger can fire workflow rules, validation rules, process builders, flows, and more DML. Every step counts against the same 150-statement budget.

Imagine an Account after update trigger that updates related Opportunities; the Opportunity after update trigger updates related OpportunityLineItems; the OpportunityLineItem trigger fires a flow that updates the parent Opportunity again. You might be one DML in your own code and twelve DML statements deep in chained automation. Production hits the cap; you can't find a loop because the loop is across SObjects, not records.

The diagnostic move is to add a debug line near the failure point:

System.debug('DML so far: '
    + Limits.getDmlStatements() + '/' + Limits.getLimitDmlStatements()
    + '; DML rows: '
    + Limits.getDmlRows() + '/' + Limits.getLimitDmlRows());

Drop that at the top and bottom of every trigger and class method in the chain. The first instance where the count jumps unexpectedly is your culprit.

The other DML cap: 10,000 rows per transaction

The 150-statement governor is paired with a 10,000-row governor. The platform allows up to 10,000 records to be touched by DML in a single transaction, summed across all DML statements. A single update bigList that touches 9,999 rows fits; the same call with 10,001 rows hits Too many DML rows: 10001.

The fix for the row cap is structural: process records in chunks, or use Batch Apex (which gets a fresh 10,000-row budget per execute() invocation). For bulk-data flows that consistently bump into the row cap, Batch Apex is the right home.

Workflow rule and flow contributions

If your trigger does one DML and the org runs three workflow rules with field updates plus a Flow that creates a task, the savings count is:

  • Your DML: 1 statement
  • Workflow rule field update (recursive save): 1 statement per recursive pass
  • Flow create-task action: 1 statement
  • Trigger re-entry: 1 statement per re-entry

A field update plus a flow plus trigger recursion easily reaches 4 to 5 statements per record. Multiply by 30 records and you're at 150.

Audit the automation footprint by going to Setup → Object Manager → your object → Flows / Process Builder / Workflow Rules. Count the active automations. If you have more than a handful firing on the same DML, expect to hit the cap in any non-trivial batch.

Escaping the cap with async

If you genuinely need 200 DML statements (say, sending 200 different update payloads to 200 different external systems via callouts), async is the escape. A Queueable job gets its own fresh 150-statement budget. Chaining 5 Queueable jobs lets you span 750 statements across the same logical operation, with each chain link starting clean.

Three patterns:

  • @future(callout=true) for fire-and-forget async work with callouts.
  • Queueable for chained async work that returns identifiable job ids.
  • Batch Apex for very large data sets where each chunk gets a fresh governor budget.

The async path adds latency and complexity. Use it when the synchronous path genuinely doesn't fit, not as a workaround for code that hasn't been bulkified.

Static analysis catches most cases

The Salesforce Code Analyzer flags DML in loops by default. So does PMD's Apex ruleset (rule OperationWithLimitsInLoop). Run either in CI on every pull request and the bug class disappears at PR time.

For an existing codebase, a one-time grep for the pattern for (.*:.*\\) followed within ten lines by insert, update, delete, or upsert catches most legacy instances. Each match is a candidate for bulkification.

A subtle source: utility classes called inside loops

DML in a loop is easy to spot when the keyword update sits two lines below for. It's harder when the loop calls a helper method that does the DML.

// Looks innocent. Each iteration calls saveOne which does DML.
for (Case c : Trigger.new) {
    CaseUtils.saveOne(c);
}
public class CaseUtils {
    public static void saveOne(Case c) {
        update c;  // Hidden DML
    }
}

The trigger doesn't show DML in its own code; the helper does. The total DML statement count is still N, where N is the number of records in the loop. This is the shape that defeats junior code review: the trigger looks clean, the helper looks clean, and the bug lives in the contract between them.

The fix is the same as the inline case: collect, then call a bulk version of the helper that takes a list.

List<Case> toSave = new List<Case>(Trigger.new);
CaseUtils.saveAll(toSave);

// Helper that does one DML for any list size:
public class CaseUtils {
    public static void saveAll(List<Case> cases) {
        if (!cases.isEmpty()) update cases;
    }
}

Adopt the rule "every helper method that does DML takes a collection, not a single record." Single-record helpers are a footgun in shared codebases.

What you should test on every bulk-DML class

A standard suite of three tests catches both governor failures and partial-success bugs:

  1. The empty-list test. Pass an empty list. The method should be a no-op with no DML at all. Confirm with System.assertEquals(0, Limits.getDmlStatements(), 'expected zero DML for empty input').

  2. The bulk test. Pass 200 records. The method should complete inside the governor budget. The Apex test harness defaults to a single-record bulk size of 200, which matches the largest record count a trigger can see in a single invocation.

  3. The partial-failure test. Pass a list where one record violates a validation rule. The method should report the failure for that one record without rolling back the others. This catches the difference between update list (all-or-nothing) and Database.update(list, false) (partial success).

The bulk test is the one that catches Too many DML statements: 151. Run it on every new class that does DML and the error class disappears from your production logs.

What the limit does not protect you from

A bulkified piece of code can still cause production pain in ways the 150-statement governor doesn't catch:

  • A single update bigList that updates 9,000 rows triggers downstream automation on 9,000 rows, which can hit other governors (CPU time, query rows, future call limits).
  • An upsert on a list with duplicate keys throws a different exception (DUPLICATE_VALUE on the relevant external id field).
  • A delete of records with cascade-delete children can blow past the row cap silently if the child counts are unknown.

Treat the 150-statement governor as one of several rails, not a single fence. The Limits class exposes the full set, and Limits.getCpuTime(), Limits.getQueries(), Limits.getFutureCalls(), and Limits.getCallouts() are the other commonly-hit ones.

Further reading from Salesforce

Related dictionary terms

Share this fix

Share on LinkedInShare on X

Related Governor limit errors