- Fix Guides
- How to Fix Salesforce Apex Trigger Recursion
How to Fix Salesforce Apex Trigger Recursion
Step-by-step fix guide with AI-powered diagnosis from BuildForce.
Salesforce throws 'Maximum trigger depth exceeded (16)' when a trigger updates a record that re-fires the same trigger, which updates again, looping 17 times. The fix is to add a static Set<Id> guard at the trigger or handler level that tracks records already processed in the current transaction context, ensure trigger handlers separate before/after logic correctly (DML inside an after trigger fires another transaction; field updates in before don't), consolidate to one trigger per object using a handler framework, and audit workflow rules and flows that may also update the same record from a separate path.
Symptoms
Error: 'Maximum trigger depth exceeded' during DML or scheduled jobs
Apex test runs hitting the 16-level recursion limit consistently
Same trigger logic running multiple times per save in debug logs
Field updates compounding (e.g., counter incrementing 4 times instead of 1)
Trigger working in unit tests but failing in production with high record volumes
Workflow + trigger combination producing inconsistent final field values
Root Causes
Trigger updating its own record without a guard
A before-update trigger that DMLs the same record (or an after-update trigger that updates Trigger.new records) causes immediate recursion. Without a static guard tracking processed IDs, the second invocation fires the trigger again, looping until the platform limit.
Workflow rule field update re-firing trigger
When a workflow rule does a field update on the same record, Salesforce re-fires the trigger (this is by design — workflow updates aren't transparent to triggers). If the trigger writes back to a field that the workflow watches, the loop is structural.
Multiple triggers on same object with overlapping logic
Orgs with 5+ triggers on Account (one per feature) each fire on every update, and any one that does an inner DML causes the others to re-fire. The one-trigger-per-object pattern with a centralized handler is the only sustainable architecture.
Flow updating the record that fires a trigger that updates flow's lookup
A flow on Account → updates Contact.AccountId → fires Contact trigger → which updates Contact.OwnerId → which fires the flow's re-evaluation → which updates back. Cross-object loops are harder to detect than single-object recursion.
@future or Queueable callback re-entering trigger
Async callbacks that DML the original record fire its trigger fresh. If the trigger's before-context logic enqueues another @future, you get an async recursion loop that exhausts the org's async limit before throwing.
How to Fix It — Step by Step
Add a static Set<Id> guard at the trigger entry point
The single most effective fix. Track records being processed in a static Set. Skip records that are already in the set. The static lives for the duration of the transaction, so genuine separate saves still process correctly.
public class AccountTriggerHandler {
private static Set<Id> processedIds = new Set<Id>();
public static void handleBeforeUpdate(List<Account> newAccs, Map<Id, Account> oldMap) {
List<Account> toProcess = new List<Account>();
for (Account a : newAccs) {
if (!processedIds.contains(a.Id)) {
processedIds.add(a.Id);
toProcess.add(a);
}
}
if (toProcess.isEmpty()) return;
// proceed with logic
}
}Consolidate to one trigger per object with a handler class
Replace all triggers on the object with a single trigger that routes to a handler class. The handler has methods like handleBeforeInsert, handleAfterUpdate, etc. Centralizing makes recursion guards effective — multiple triggers each with their own guard miss cross-trigger recursion.
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
if (Trigger.isBefore && Trigger.isUpdate) AccountTriggerHandler.handleBeforeUpdate(Trigger.new, Trigger.oldMap);
if (Trigger.isAfter && Trigger.isUpdate) AccountTriggerHandler.handleAfterUpdate(Trigger.new, Trigger.oldMap);
// ... other contexts
}Move field updates from after-trigger to before-trigger context
Updating fields on Trigger.new records in before-context doesn't fire another transaction — it modifies the in-flight save. Doing the same in after-context requires explicit DML which fires the trigger again. Default to before-context for self-field updates.
// BEFORE context — no recursion
for (Account a : Trigger.new) {
a.NormalizedName__c = a.Name.toLowerCase();
// no DML needed
}Audit workflow rules that may cross with trigger logic
In Setup → Workflow Rules, list all active rules on the object. For each rule with field updates, identify whether the trigger watches or writes those fields. Trigger + workflow on the same field combination is the most common cross-path recursion source.
Use Trigger.isExecuting and Trigger.size for diagnostic logging
Add a diagnostic line at the top of every trigger handler that logs Trigger.size and a recursion counter. In debug logs, you'll see exactly how many times the trigger fires per save and at what depth.
private static Integer invocationCount = 0;
public static void handle(List<SObject> records) {
invocationCount++;
System.debug('Trigger invocation #' + invocationCount + ' size=' + records.size());
// ...
}Separate static guards by operation type
If a single trigger handler processes both inserts and updates, use separate Set<Id> guards per operation. A record that inserted in this transaction may legitimately need to update later in the same transaction — a single guard would block it.
Add an org-wide trigger recursion monitor
BuildForce's trigger health check audits every object's trigger architecture: counts triggers per object, identifies missing static guards, surfaces workflow+trigger cross-paths, and reports the deepest recursion observed in production. Catch recursion risk before users hit the limit.
Let BuildForce diagnose and fix this automatically
Instead of following manual steps, connect your org and let our AI identify exactly what's broken and how to fix it — in minutes.
Book a DemoCommon Questions
More answers about this issue and how to resolve it.
Stop debugging manually. Let AI do it.
BuildForce runs 200+ automated checks across your Salesforce org and tells you exactly what's broken and how to fix it.