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.

The short answer: your transaction is touching more than 10,000 records with DML, and no amount of bulkification fixes that — bulkification reduces the number of statements, not the number of rows. Either narrow the working set with a more selective filter, or move the write into Batch Apex, where the row counter resets for every chunk. If you convert this to a batch job, be honest about what you give up: the work becomes eventually consistent and partially committed on failure.

Statements and rows are two different limits

People arrive at this error after fixing the more famous one and are surprised that consolidating DML into a single call made no difference. It wouldn’t. One update of 10,001 records is a single statement and 10,001 rows.

Limit Synchronous Asynchronous
Records processed by DML 10,000 10,000
DML statements issued 150 150
Records retrieved by SOQL 50,000 50,000
Records retrieved by Database.getQueryLocator 10,000 10,000

Note the gap between the two SOQL numbers and the DML number. You are allowed to read 50,000 records in a transaction but only write 10,000, which is why a job that queries fine still explodes on save. The counter is cumulative across the whole transaction and includes rows processed by Approval.process and Database.emptyRecycleBin, not just your own DML calls.

Cause 1: a rollup or recalculation over an unbounded child set

The most common shape is code that updates every child of every record in the trigger batch. With 200 parents and 60 children each, that’s 12,000 rows from a completely reasonable-looking bulk update.

// 200 Accounts x an unknown number of Contacts. No ceiling.
List<Contact> toUpdate = [
    SELECT Id, Account_Tier__c FROM Contact
    WHERE AccountId IN :Trigger.newMap.keySet()
];
for (Contact c : toUpdate) {
    c.Account_Tier__c = tierByAccount.get(c.AccountId);
}
update toUpdate;

The first question to ask is whether those children need updating at all. Filter to the records whose value actually changes, in the query if you can express it there and in memory if you can’t:

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

for (Contact c : [
    SELECT Id, AccountId, Account_Tier__c FROM Contact
    WHERE AccountId IN :changedAccountIds
]) {
    String newTier = tierByAccount.get(c.AccountId);
    if (c.Account_Tier__c != newTier) {
        c.Account_Tier__c = newTier;
        toUpdate.add(c);
    }
}

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

Two things improved. The row count now reflects real changes rather than the size of the child table, and you stopped firing the Contact trigger for records that did not change, which is often where the rest of the transaction’s budget was going.

Also narrow changedAccountIds to Accounts where the tier field actually changed, by comparing Trigger.oldMap. A mass data load that touches an unrelated field on 200 Accounts should not recalculate a single Contact.

Cause 2: the field-denormalization design is the problem, not the code

If your data model copies a parent value onto every child, every parent edit is a bulk child write, and the row count scales with your data volume forever. That is a design that gets slower every quarter.

Where the child value is only ever read for display or filtering, a formula field crossing the lookup costs zero DML rows and cannot go stale. The tradeoff is real: formula fields are not indexed the way stored fields are, cannot be used in every context a stored field can, and evaluating them adds query cost on wide reports. But trading report performance for the elimination of an entire class of governor failure is usually the right side of the deal. I’d reach for the formula first and only denormalize when a specific query pattern proves it necessary.

Cause 3: cascade deletes, which you never wrote

Deleting a parent deletes its master-detail children, and those child rows count against your budget. Deleting 500 Accounts with 25 child records each is 13,000 rows from one statement.

Measure it rather than reasoning about it, because the multiplier depends on your schema:

Integer before = Limits.getDMLRows();
delete accountsToRemove;
System.debug('Rows consumed by delete: ' + (Limits.getDMLRows() - before));

If the answer is far larger than the list you passed in, you found your cascade. Emptying the recycle bin afterwards spends rows again, so Database.emptyRecycleBin() in the same transaction as the delete roughly doubles the cost.

Cause 4: recursion multiplying an otherwise-fine write

A single 4,000-row update that runs three times because of trigger re-entry is 12,000 rows. The counter does not reset between passes.

Check this before you assume the volume is genuine. Print Limits.getDMLRows() at the entry point of each handler. If the same handler logs three ascending values, the volume is the same work repeated, and a static re-entry guard fixes it without touching your data model at all.

Cause 5: batch scope set too high

Batch Apex resets governor limits for each execute call, but a single execute is still one transaction with one 10,000-row budget. If each record in scope produces multiple writes, a large scope size overruns it.

// Each Account in scope generates ~30 child updates.
// 2000 x 30 = 60,000 rows in a single execute. Fails.
Database.executeBatch(new AccountRefreshBatch(), 2000);

// 200 x 30 = 6,000 rows. Fits, with headroom.
Database.executeBatch(new AccountRefreshBatch(), 200);

The default scope when you omit the parameter is 200. Reducing it is the cheapest fix available and costs you only wall-clock time — more chunks, more total overhead, a job that takes longer to finish. Do the arithmetic explicitly: divide 10,000 by your worst-case writes per record, then halve it for safety.

Diagnose it

Set the Apex Code log level to FINEST and read the CUMULATIVE_LIMIT_USAGE section at the end of the log. The LIMIT_USAGE_FOR_NS lines break the row count out per namespace, so you learn immediately whether your code or an installed package spent the budget. That distinction changes the fix entirely and takes thirty seconds to establish.

In code, instrument the boundaries:

System.debug('DML rows: ' + Limits.getDMLRows()
    + ' of ' + Limits.getLimitDMLRows());

Confirm the fix

Test at a volume that would have failed before. If the pre-fix path wrote 60 rows per parent record, insert enough parents to cross 10,000 and assert the ceiling:

@IsTest
static void refreshStaysUnderRowLimit() {
    List<Account> accounts = TestData.accountsWithContacts(200, 60);

    Test.startTest();
    AccountRefreshService.run(accounts);
    Test.stopTest();

    System.assert(Limits.getDMLRows() < 10000,
        'Row count still scales with child volume: ' + Limits.getDMLRows());
}

For a batch job, the equivalent proof is to run one execute against a realistic worst-case scope inside Test.startTest() and assert the same thing. Testing the batch with five records tells you the code compiles, nothing more.

Preventing the next one

  • Write only changed records. Compare old and new values before adding to the update list. This one habit prevents most row-limit failures and most recursion.
  • Know your fan-out. For any code that writes children, state the worst-case children-per-parent number and confirm scope size against it.
  • Prefer formula and rollup summary fields to copied values where the value is only read.
  • Assert Limits.getDMLRows() in tests at production-shaped volume, not at the volume that is convenient to set up.

Measured in a Summer ’26 scratch org on 14 September 2026: inserting 10 records consumed 10 DML rows and 1 statement. Deleting the same 10 took the running totals to 20 rows and 2 statements. Deletes count against the row limit exactly as inserts do, which is what catches a cleanup job written as an afterthought.

Frequently asked

I already consolidated my DML into one statement. Why am I still failing?

Because this limit counts records, not calls. Consolidating statements fixes the 150 limit and has no effect on the 10,000-row limit.

Does asynchronous execution give me more rows?

Not per transaction — it is 10,000 in both contexts. Batch Apex helps because each execute invocation is a separate transaction with a fresh 10,000, so a job can process millions of records in total. What you give up is atomicity and immediacy: the work completes over minutes or hours, and if chunk 40 fails, chunks 1 through 39 stay committed.

Do rows deleted by a cascade count?

Yes. Any record the platform writes as a consequence of your DML counts, including master-detail children removed by a cascade delete. Measure it with Limits.getDMLRows() either side of the call.

Does a failed or partially successful DML call still consume rows?

Yes. Database.update(records, false) attempts every row, so the whole list counts whether individual rows succeeded or errored.

Is Database.getQueryLocator‘s 10,000 the same limit?

No, it’s a separate one that happens to share the number. A query locator used outside a batch context returns at most 10,000 records. Inside a batch start method it streams millions, which is the main reason batch exists.


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