System.LimitException: Maximum trigger depth exceeded
Triggers can fire other triggers, which can fire more triggers — but only 16 levels deep. Hit 17 and the platform stops the whole transaction. Distinct from 1,000-deep stack overflow, which is method recursion within Apex; this one counts cascading DML across triggers.
Also seen asMaximum trigger depth exceeded·Maximum trigger depth·trigger depth limit·Cascading trigger depth
A platform engineer deploys a new feature that synchronizes Account and Contact records: when an Account's primary contact changes, the Contact's mailing address copies from the Account, and vice versa. The first manual edit to a test record locks up the page for ten seconds, then surfaces System.LimitException: Maximum trigger depth exceeded. Both records show partial updates. The engineer rolls back the deploy and looks for the cause.
What the platform is checking
Apex enforces a maximum trigger recursion depth of 16 within a single transaction. Each time a trigger fires, the platform increments a counter. When the same trigger fires for the 17th time on any record in the same transaction, the platform throws LimitException: Maximum trigger depth exceeded. The transaction aborts and any uncommitted DML rolls back.
The limit protects the platform from unbounded recursive cascades. A trigger on Account that updates Contact, which has a trigger that updates Account, which has a trigger that updates Contact, can loop without natural termination. The 16-pass cap forces such loops to fail visibly rather than consume resources indefinitely.
The depth counter is per trigger and per object combination, not a single global counter. A trigger on Account firing for the 16th time produces the error, even if the trigger on Contact also fired 15 times in the same transaction. The cap is sometimes described as "16 levels" because that is the practical observed depth before the platform forces the failure.
Most well-built triggers never approach the limit. Typical updates fire a trigger one to three times across the entire Save Order of Execution. Hitting 16 means the trigger is being re-fired by something downstream of its own DML, and the downstream automation is firing the trigger again.
The cause is structurally identical to the stack-depth limit: a cycle. The difference is that stack depth is about method calls within a transaction; trigger depth is about trigger invocations across DML steps. The fixes are similar: break the cycle with a guard or restructure the logic to avoid the cycle.
What is in cycle
Three common patterns produce most trigger-depth failures.
Two-object reciprocal triggers. A trigger on Account updates a Contact field. The Contact has a trigger that updates an Account field. The Account update fires the Account trigger again, which updates Contact, which fires the Contact trigger again. The cycle continues until the depth cap fires.
Trigger that updates its own parent. A trigger on Opportunity updates the parent Account. The Account trigger updates the Opportunity (recomputes a rollup or stamps a field). The Opportunity update fires the Opportunity trigger again, which updates the Account again, in a self-reinforcing loop.
Flow re-entry through a trigger. A trigger updates a field. A record-triggered flow on the same object fires because the field changed. The flow updates another field. The trigger fires again because that field changed. The flow fires again because the trigger's update meets its entry criteria. The depth grows.
The broken example
Two triggers that synchronize address fields between Account and primary Contact:
trigger AccountSyncTrigger on Account (after update) {
Set<Id> contactIds = new Set<Id>();
Map<Id, Account> accountsById = new Map<Id, Account>(Trigger.new);
for (Account a : Trigger.new) {
if (a.Primary_Contact__c != null) {
contactIds.add(a.Primary_Contact__c);
}
}
List<Contact> contactsToUpdate = new List<Contact>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE Id IN :contactIds]) {
Account a = accountsById.get(c.AccountId);
contactsToUpdate.add(new Contact(
Id = c.Id,
MailingStreet = a.BillingStreet,
MailingCity = a.BillingCity,
MailingState = a.BillingState,
MailingPostalCode = a.BillingPostalCode
));
}
update contactsToUpdate;
}
trigger ContactSyncTrigger on Contact (after update) {
Set<Id> accountIds = new Set<Id>();
for (Contact c : Trigger.new) {
if (c.AccountId != null) accountIds.add(c.AccountId);
}
List<Account> accountsToUpdate = new List<Account>();
for (Account a : [SELECT Id FROM Account WHERE Id IN :accountIds]) {
Contact firstContact = [SELECT MailingStreet, MailingCity, MailingState, MailingPostalCode
FROM Contact WHERE AccountId = :a.Id LIMIT 1];
accountsToUpdate.add(new Account(
Id = a.Id,
BillingStreet = firstContact.MailingStreet,
BillingCity = firstContact.MailingCity,
BillingState = firstContact.MailingState,
BillingPostalCode = firstContact.MailingPostalCode
));
}
update accountsToUpdate;
}
A user edits an Account's BillingStreet in the UI. The Account trigger fires (depth 1 for Account). It updates the related Contact. The Contact trigger fires (depth 1 for Contact). It updates the Account again. The Account trigger fires (depth 2 for Account). And so on. After 16 round trips, the depth cap fires.
Even worse, the data is now inconsistent. The Contact has half-updated address fields. The Account is rolled back to its pre-save state. The user thinks they saved a record; the records are corrupt.
The fix, three paths
Guard the recursion with a static flag. A class-level static boolean tracks whether the trigger has already fired in this transaction. The first invocation sets the flag and runs. Subsequent invocations short-circuit.
public class SyncTriggerControl {
public static Boolean accountSyncRunning = false;
public static Boolean contactSyncRunning = false;
}
trigger AccountSyncTrigger on Account (after update) {
if (SyncTriggerControl.accountSyncRunning) return;
SyncTriggerControl.accountSyncRunning = true;
try {
// sync logic here
} finally {
SyncTriggerControl.accountSyncRunning = false;
}
}
trigger ContactSyncTrigger on Contact (after update) {
if (SyncTriggerControl.contactSyncRunning) return;
SyncTriggerControl.contactSyncRunning = true;
try {
// sync logic here
} finally {
SyncTriggerControl.contactSyncRunning = false;
}
}
The guard prevents re-entry. When the Account trigger calls the Contact trigger, the second Account trigger pass sees the flag set and exits immediately. The cycle terminates after one pass on each side.
Check whether the change is meaningful before propagating. Many recursion cycles can be broken by checking whether the propagated update actually changes the field. The Account trigger only writes to Contact when the Account fields differ from the Contact fields. The Contact trigger only writes to Account when the Contact fields differ from the Account fields.
trigger AccountSyncTrigger on Account (after update) {
Set<Id> contactIds = new Set<Id>();
Map<Id, Account> accountsById = new Map<Id, Account>(Trigger.new);
for (Account a : Trigger.new) {
if (a.Primary_Contact__c != null) contactIds.add(a.Primary_Contact__c);
}
Map<Id, Contact> currentContacts = new Map<Id, Contact>([
SELECT Id, AccountId, MailingStreet, MailingCity, MailingState, MailingPostalCode
FROM Contact WHERE Id IN :contactIds
]);
List<Contact> contactsToUpdate = new List<Contact>();
for (Contact c : currentContacts.values()) {
Account a = accountsById.get(c.AccountId);
if (c.MailingStreet != a.BillingStreet || c.MailingCity != a.BillingCity ||
c.MailingState != a.BillingState || c.MailingPostalCode != a.BillingPostalCode) {
contactsToUpdate.add(new Contact(
Id = c.Id,
MailingStreet = a.BillingStreet,
MailingCity = a.BillingCity,
MailingState = a.BillingState,
MailingPostalCode = a.BillingPostalCode
));
}
}
if (!contactsToUpdate.isEmpty()) {
update contactsToUpdate;
}
}
The trigger now skips the update entirely when the fields are already in sync. The first round of propagation makes the records match. The second round sees them in sync and does nothing. The recursion terminates naturally.
Designate one record as the source of truth. Reciprocal sync is structurally problematic. A cleaner design picks one record as authoritative and propagates one-way. Account is the source; Contact reads from Account. The Contact trigger does not write back to Account.
trigger AccountSyncTrigger on Account (after update) {
// Account is the source; propagate to Contact
}
// Contact trigger does not write to Account at all
The data flows in one direction. There is no cycle to break because there is no return path.
The fixed example
A handler-based pattern with one-way propagation and a recursion guard for safety:
public class AccountTriggerHandler {
private static Boolean isRunning = false;
public static void handleAfterUpdate(List<Account> newRecords, Map<Id, Account> oldMap) {
if (isRunning) return;
isRunning = true;
try {
syncAddressesToContacts(newRecords, oldMap);
} finally {
isRunning = false;
}
}
private static void syncAddressesToContacts(List<Account> newRecords, Map<Id, Account> oldMap) {
Set<Id> accountIdsToSync = new Set<Id>();
for (Account a : newRecords) {
Account oldA = oldMap.get(a.Id);
if (addressChanged(a, oldA)) {
accountIdsToSync.add(a.Id);
}
}
if (accountIdsToSync.isEmpty()) return;
Map<Id, Account> currentAccounts = new Map<Id, Account>([
SELECT Id, BillingStreet, BillingCity, BillingState, BillingPostalCode
FROM Account WHERE Id IN :accountIdsToSync
]);
List<Contact> toUpdate = new List<Contact>();
for (Contact c : [
SELECT Id, AccountId, MailingStreet, MailingCity, MailingState, MailingPostalCode
FROM Contact WHERE AccountId IN :accountIdsToSync
]) {
Account a = currentAccounts.get(c.AccountId);
if (needsUpdate(c, a)) {
toUpdate.add(new Contact(
Id = c.Id,
MailingStreet = a.BillingStreet,
MailingCity = a.BillingCity,
MailingState = a.BillingState,
MailingPostalCode = a.BillingPostalCode
));
}
}
if (!toUpdate.isEmpty()) update toUpdate;
}
private static Boolean addressChanged(Account newA, Account oldA) {
return newA.BillingStreet != oldA.BillingStreet ||
newA.BillingCity != oldA.BillingCity ||
newA.BillingState != oldA.BillingState ||
newA.BillingPostalCode != oldA.BillingPostalCode;
}
private static Boolean needsUpdate(Contact c, Account a) {
return c.MailingStreet != a.BillingStreet ||
c.MailingCity != a.BillingCity ||
c.MailingState != a.BillingState ||
c.MailingPostalCode != a.BillingPostalCode;
}
}
trigger AccountTrigger on Account (after update) {
AccountTriggerHandler.handleAfterUpdate(Trigger.new, Trigger.oldMap);
}
The handler uses three protections at once: the static guard prevents re-entry, the change check skips updates when fields already match, and the design propagates one-way from Account to Contact. The Contact trigger does not push back to Account.
Edge cases and gotchas
The depth counter is platform-wide. A workflow rule with field update that meets re-evaluation criteria counts toward the trigger depth. A flow that performs an update counts. The trigger depth limit is reached by the cumulative cascade, not just by direct DML from your own code.
Different triggers, same depth pool. Multiple triggers on the same object share the depth counter for that object. If three different triggers on Opportunity each fire once on a save, the Opportunity depth counter is 3, not 1 per trigger.
Async DML resets the depth. A queueable or future method enqueued from within a trigger runs in a separate transaction with a fresh depth counter. Long sync cascades can be offloaded to async to break the chain.
Test methods and Trigger.new. Tests that insert many records in a single DML can hit the depth more easily than production saves of single records. A test that inserts 100 Opportunities in one call fires triggers once for all 100; that single firing is depth 1.
Recursion guards must reset across DML in tests. A test that exercises multiple DML operations in sequence needs to reset the static recursion guard between operations, or the second DML sees the guard set and short-circuits incorrectly.
Bulk patterns matter. A trigger that updates one record per source record (instead of bulkifying) produces N DML statements for N source records. Each DML can fire follow-on triggers. Bulkifying to a single DML per source object cuts the depth growth dramatically.
Defensive habits
Adopt a trigger framework. Frameworks like Kevin O'Hara's TriggerHandler or the FFLib Apex Common framework bake in recursion guards by default. Every trigger that uses the framework gets the guard automatically.
One trigger per object, dispatching to a handler. Multiple triggers on the same object compound depth issues. A single trigger that calls a handler class keeps the control flow explicit and the depth predictable.
Check before propagating. The cheapest recursion guard is "do not propagate if the data already matches". A field comparison is a few comparisons; the saved DML is many milliseconds of work.
Design one-way data flows. Reciprocal sync is structurally problematic and almost always introduces unexpected cycles. Designating an authoritative record and propagating one-way is cleaner, easier to reason about, and harder to break.
Test cross-object cascades. A test that updates an Account and asserts the resulting Contact state catches the depth limit early. Single-object tests miss the multi-hop cascade that produces this error in production.
Test patterns
A test that exercises the sync without triggering depth:
@IsTest
static void accountAddressUpdatePropagatesToContact() {
Account a = new Account(
Name = 'Test',
BillingStreet = '1 Main St',
BillingCity = 'Anytown',
BillingState = 'CA',
BillingPostalCode = '94000'
);
insert a;
Contact c = new Contact(
FirstName = 'Test',
LastName = 'User',
AccountId = a.Id
);
insert c;
Test.startTest();
a.BillingStreet = '2 Oak Ave';
update a;
Test.stopTest();
Contact updated = [SELECT MailingStreet FROM Contact WHERE Id = :c.Id];
System.assertEquals('2 Oak Ave', updated.MailingStreet);
}
The test confirms a single Account update results in a single Contact update with no depth issues. A regression test should also exercise the case where the addresses are already in sync to confirm no update fires.
Diagnosing in production
When the LimitException fires:
- Capture the debug log from the failing transaction.
- Identify the triggers that fired and the order they fired in.
- Look for the recursive pattern: trigger A updates record B, trigger B updates record A, repeat.
- Identify the cycle and choose a fix.
- Add a recursion guard or restructure the propagation.
The trigger depth limit is sometimes reached by very expensive triggers that legitimately need to cascade. A roll-up on a deep hierarchy can hit depth 10 without any bug. Distinguish "legitimate deep cascade" from "buggy cycle" by reading the debug log carefully.
Quick recovery checklist
- Identify the cycling triggers.
- Add a recursion guard or change-detection check.
- Restructure to one-way propagation if possible.
- Adopt a trigger framework.
- Add regression tests for the multi-hop cascade.
Most trigger-depth incidents are caused by reciprocal triggers added by different developers at different times. The fix is mechanical once the cycle is identified and the team agrees on the new direction of data flow.
Further reading from Salesforce
Related dictionary terms
Share this fix
Related Governor limit errors
Apex code is approaching a governor limit warning email
Governor limitSalesforce sent your team an email saying Apex is approaching a governor limit (typically 80% of CPU, SOQL, DML, or callout caps) but didn't…
STORAGE_LIMIT_EXCEEDED: storage limit exceeded
Governor limitYour org has hit its data storage or file storage cap. New record creation fails until you free space or buy more storage. Audit which objec…
System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out
Governor limitYou did a DML statement and then tried to make an HTTP callout in the same synchronous transaction. The platform forbids this because the ca…
System.LimitException: Apex CPU time limit exceeded
Governor limitYour transaction spent more than 10 seconds (sync) or 60 seconds (async) of CPU time inside Apex code. This counts compute, not waiting — SO…
System.LimitException: Apex heap size too large
Governor limitYour transaction is holding more data in memory at once than Apex allows: 6 MB synchronous, 12 MB asynchronous. Usually it's a `List<SObject…