The answer, briefly: a static boolean stops recursion and also stops legitimate second runs. It is the right fix for a trigger that updates its own object and the wrong fix for almost everything else, because a static survives the whole transaction and cannot tell “I already did this record” from “this record came back for a different reason”.
What recursion actually looks like
Your after-update trigger updates the same records. That update fires the trigger again. Without a guard you either hit the maximum trigger depth of 16 or, far more often, do the same work twice and watch a counter field increment by two.
The second version is worse because nothing errors. An email sends twice, a roll-up doubles, an integration receives two events. Nobody notices for a month.
The static flag, and what it costs
public class AccountTriggerHandler {
private static Boolean hasRun = false;
public static void run() {
if (hasRun) { return; }
hasRun = true;
// ...
}
}
Statics persist for the life of the transaction. That is the feature and the problem. Three cases where it silently drops work you wanted:
- A workflow field update or a related trigger updates your object again. That is a real change with real new values, and your guard skips it.
- Two separate DML statements in one transaction. A service that inserts accounts, then later updates a different set of accounts, processes the second set not at all.
- A partial-success DML with
allOrNone = false, where a retry of the failed rows is skipped entirely.
The symptom is distinctive and misleading: everything works in a simple test, and a field is mysteriously not set when the record is saved as part of something larger.
The guard that does not lose work
Track which records you have already processed, for which operation:
public class TriggerGuard {
private static Map<String, Set<Id>> processed = new Map<String, Set<Id>>();
public static Set<Id> notYetProcessed(String key, Set<Id> candidates) {
if (!processed.containsKey(key)) { processed.put(key, new Set<Id>()); }
Set<Id> fresh = new Set<Id>(candidates);
fresh.removeAll(processed.get(key));
processed.get(key).addAll(fresh);
return fresh;
}
}
Called as TriggerGuard.notYetProcessed('AccountRollup', Trigger.newMap.keySet()), this processes each record once per operation and leaves the rest of the transaction alone. The key matters: the same record legitimately passes through several different pieces of logic.
Often the right answer is not a guard at all
Before adding one, ask why the trigger is updating its own records. Two common cases have better fixes:
Setting a field on the triggering record. Do it in a before trigger. No DML, no recursion, no guard, and no extra limits consumed. Code doing this in an after trigger with an explicit update is doing twice the work to create a problem it then has to solve.
Maintaining a roll-up on a parent. The parent’s own trigger is what re-fires, not the child’s. If that parent trigger does not need to react to the roll-up field changing, filter on the fields you actually care about rather than guarding globally.
Check whether the field changed
The cheapest guard of all is not running when nothing relevant changed:
for (Account a : Trigger.new) {
Account old = Trigger.oldMap.get(a.Id);
if (a.AnnualRevenue != old.AnnualRevenue) { toProcess.add(a); }
}
This costs nothing, needs no state, survives every transaction shape, and eliminates most recursion by making the second pass a no-op naturally. It is the first thing to try and the last thing people reach for.
Batch and async reset it
A static resets between transactions, so each batch execute chunk starts fresh. That is usually what you want, and it means a guard that works in a trigger does nothing to prevent repeated work across a batch. If you need that, the state has to live in the database.
Frequently asked
What is the maximum recursion depth? 16 for triggers. You will hit correctness problems long before you hit it.
Should the guard be in the trigger or the handler? The handler. The trigger should contain no logic at all, as covered in trigger frameworks.
Do flows need this? Yes, and the equivalent is the entry condition “only when a record is updated to meet the criteria”, which is the cheapest fix for a flow that fires twice.
