Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

In one sentence: you have a DML statement inside a loop. Salesforce allows 150 DML statements per transaction, and the exception fires on the 151st attempt. Collect the records you want to change into a List inside the loop, then issue one insert or update after the loop closes. Unlike the SOQL query limit, this one does not get more generous asynchronously, so moving the code to a batch job will not save you.

What the number counts, and what it doesn’t

The limit counts statements, not records. One update of a single Account costs the same as one update of 10,000 Accounts: one statement. That asymmetry is the whole fix — a single bulk DML call is essentially free compared to the loop that issued 200 small ones.

Records are governed separately, at 10,000 processed rows per transaction. You can trip either one independently, and the error messages are different, so read the exception text before you start optimizing the wrong thing.

Limit Synchronous Asynchronous
DML statements issued 150 150
Records processed by DML 10,000 10,000
SOQL queries issued 100 200
Records retrieved by SOQL 50,000 50,000
CPU time 10,000 ms 60,000 ms

Everything that runs in the transaction shares the 150. Your trigger, the three flows on the same object, the managed package’s trigger, and the record-triggered flow that fires on the child object all draw from one pool. So does every savepoint you set — Database.setSavepoint() consumes a DML statement, which you can confirm for yourself by printing Limits.getDMLStatements() either side of the call.

Cause 1: the DML is inside the for loop

This is the culprit the overwhelming majority of the time.

// One update per record. Dies on the 151st Opportunity.
for (Opportunity opp : Trigger.new) {
    Account a = accountsById.get(opp.AccountId);
    a.Description = 'Last deal: ' + opp.Name;
    update a;
}

Move the DML out and let the collection do the batching:

List<Account> toUpdate = new List<Account>();

for (Opportunity opp : Trigger.new) {
    Account a = accountsById.get(opp.AccountId);
    if (a == null) { continue; }
    a.Description = 'Last deal: ' + opp.Name;
    toUpdate.add(a);
}

if (!toUpdate.isEmpty()) {
    update toUpdate;
}

Two details in that rewrite matter more than they look. The isEmpty() guard is not cosmetic: DML on an empty list still counts as a statement, so in a recursive or heavily automated transaction those no-op calls add up. And because accountsById.get() returns the same object instance each time, duplicate Opportunities on one Account mutate the same record rather than queuing two conflicting updates, which is exactly what you want, and is not true if you build the list from fresh query results per iteration.

Cause 2: two objects per iteration, so you fail at 76 records

The arithmetic surprises people. If each pass through the loop performs two DML statements, the ceiling is not 150 records, it’s 75.

for (Case c : Trigger.new) {
    insert new Task(WhatId = c.Id, Subject = 'Follow up');
    update new Account(Id = c.AccountId, Priority_Case__c = true);
}

Two lists, two statements total:

List<Task> tasks = new List<Task>();
Map<Id, Account> accounts = new Map<Id, Account>();

for (Case c : Trigger.new) {
    tasks.add(new Task(WhatId = c.Id, Subject = 'Follow up'));
    if (c.AccountId != null) {
        accounts.put(c.AccountId, new Account(Id = c.AccountId, Priority_Case__c = true));
    }
}

insert tasks;
update accounts.values();

Keying the Accounts by Id in a Map rather than adding them to a List also dedupes them. Feeding the same record Id twice into one update call throws a DUPLICATE_VALUE error, and that failure is easy to miss in testing because it only appears once two Cases in a batch share an Account.

Cause 3: recursion, where the count is cumulative

Trigger A updates Accounts, the Account trigger updates Contacts, the Contact trigger writes back to the Account. Each pass is individually modest and the transaction total is not. The counter never resets inside a transaction, so five passes of 40 statements fails while no single pass looks wrong in isolation.

A static guard is the usual answer:

public class ContactRollupService {
    private static Boolean hasRun = false;

    public static void rollup(List<Contact> contacts) {
        if (hasRun) { return; }
        hasRun = true;
        // ...
    }
}

Use it knowingly. The flag suppresses the second pass completely, which is correct when that pass is redundant work and wrong when it carries changes that genuinely need processing — a partial-save retry after a row lock, for example, re-enters with records the first pass never saw. If the recursion comes from your trigger updating the record that fired it, the better fix is to stop the second save from existing: set the field in a before context instead of issuing DML in an after one.

Cause 4: a logging or utility method that writes one row per call

The loop and the DML are in different classes, so neither reads as a mistake.

for (Order o : orders) {
    try {
        process(o);
    } catch (Exception e) {
        Logger.log(o.Id, e.getMessage());   // inserts one row, every time
    }
}

Give the logger a buffer and one flush at the end of the transaction, or publish a platform event instead of inserting an sObject. Both have a real cost: a buffered log is lost if the transaction rolls back, which is precisely the scenario you built the logger for. Platform events published with the immediate publish behavior survive the rollback, but they consume their own limit — Limits.getPublishImmediateDML() reports it, and you get the log asynchronously rather than in the same debug output. Pick based on whether you need the log to survive failure or merely to exist.

Cause 5: it isn’t your code

Perfectly bulkified code can throw this exception because it happened to run 149 statements into a transaction that somebody else filled. Managed package triggers, record-triggered flows, and approval processes all spend the same budget.

Find out before you refactor anything. Set the Apex Code log level to FINEST, run the operation, and read the CUMULATIVE_LIMIT_USAGE block at the end of the log. It breaks out LIMIT_USAGE_FOR_NS per namespace, so you can see exactly how many DML statements your code spent versus each installed package. If your namespace shows 4 of 150, your code is not the problem and refactoring it further is wasted effort.

Diagnose it in code

The Limits class reports both the consumed count and the ceiling, which is the fastest way to bisect a long transaction:

System.debug('DML statements: ' + Limits.getDMLStatements()
    + ' of ' + Limits.getLimitDMLStatements()
    + ' | rows: ' + Limits.getDMLRows()
    + ' of ' + Limits.getLimitDMLRows());

Drop that at the start and end of each handler method. Whichever method shows a jump proportional to the batch size is the one with DML in a loop.

Confirm the fix at 200 records

A test that inserts one record proves nothing about a governor limit. The only test that counts inserts a full trigger batch and asserts that the statement count did not scale with it:

@IsTest
static void bulkUpdateUsesConstantDml() {
    List<Account> accounts = new List<Account>();
    for (Integer i = 0; i < 200; i++) {
        accounts.add(new Account(Name = 'Bulk ' + i));
    }
    insert accounts;

    Test.startTest();
    Integer before = Limits.getDMLStatements();
    AccountService.recalculate(accounts);
    Integer used = Limits.getDMLStatements() - before;
    Test.stopTest();

    System.assert(used <= 3, 'DML count scales with batch size: ' + used);
}

Assert an absolute ceiling like 3, not “fewer than 150”. An assertion set near the limit passes right up until the day a package is installed and then fails in production.

Preventing the next one

  • No DML keyword inside a loop body, ever. This is worth a static analysis rule in CI rather than a code review habit. PMD’s OperationWithLimitsInLoop catches it.
  • Accumulate into a Map keyed by Id when updating parent records, so duplicates collapse instead of erroring.
  • Guard every DML call with isEmpty().
  • One trigger per object routed through a handler, so you control ordering and re-entry rather than discovering it.
  • Assert limit consumption in tests at 200 records for anything that writes.

Frequently asked

Why does the error say 151 when the limit is 150?

The exception names the statement that could not run. 150 executed successfully. The 151st threw.

Does running the code asynchronously raise the limit?

No. DML statements stay at 150 in both synchronous and asynchronous contexts. Batch Apex helps for a different reason: governor limits reset for each execute call, so each chunk of 200 records gets its own 150 statements. That buys you scale at the cost of eventual consistency: the work finishes minutes later, in pieces, and a failure in one chunk leaves the others committed.

Does a DML statement on an empty list count?

Yes. update new List<Account>() spends one of your 150. Guard with isEmpty().

Do Database.update() and the DML keyword count differently?

They count the same. Database.update(records, false) gives you partial success and per-row errors rather than an all-or-nothing rollback, but it is still one statement.

Can support raise this limit for my org?

No. Per-transaction governor limits are fixed and not adjustable by support, edition, or licence count.


Reference: Per-Transaction Apex Limits, Limits Class, Use Batch Apex, and Debug Log, Salesforce Apex Developer Guide.