System.LimitException: Maximum stack depth has been reached: 1001
Your Apex code recursed 1,001 levels deep. Almost always a trigger that updates the same object, fires itself, and never has a base case. The fix is a static recursion guard, not refactoring the recursion away.
Also seen asMaximum stack depth has been reached·Maximum stack depth has been reached: 1001·stack overflow apex
A batch job that recalculates Opportunity totals runs nightly. Tuesday's run dies with System.LimitException: Maximum stack depth has been reached: 1001. The error fires in the middle of processing a record that the team didn't think was special. The Apex code looks straightforward. Somewhere, a recursive call ran away.
What the platform is checking
Apex enforces a maximum call-stack depth of 1000 frames within a single transaction. Every method call pushes a frame onto the stack; every return pops one. When the stack reaches 1001, the runtime throws LimitException: Maximum stack depth has been reached. The transaction aborts and any uncommitted DML rolls back.
The limit exists because unbounded recursion crashes the JVM and consumes resources that should be shared among other org tenants. A finite stack ceiling forces developers to think about recursion depth and to use iterative patterns when the data structure could be arbitrarily deep.
Most well-written Apex never approaches the limit; typical business logic uses a stack depth in the low double digits. Hitting 1001 nearly always means a recursive pattern is running away. The cause is usually one of three things: a trigger that updates a record on the same object and refires itself, a service method that calls itself directly or indirectly, or a deeply nested data structure (a tree of related records) being walked recursively.
The exception message is precise about the depth (1001) but not about which frame is repeating. The fix requires reading the stack trace and finding the cycle.
The broken example
A trigger that updates the record it just received:
trigger OpportunityRecalc on Opportunity (after update) {
List<Opportunity> toUpdate = new List<Opportunity>();
for (Opportunity o : Trigger.new) {
Opportunity refreshed = new Opportunity(Id=o.Id, Amount=o.Amount * 1.0);
toUpdate.add(refreshed);
}
update toUpdate; // Refires the trigger
}
The trigger fires on update. Inside, it issues an update to the same records. The update fires the trigger again. The same code runs again, issuing another update. The recursion repeats until the stack reaches 1001 frames.
A second shape: a service method that calls a helper that calls back into the original method:
public class AccountService {
public static void rollupOpportunities(Id accountId) {
Account a = [SELECT Id FROM Account WHERE Id = :accountId];
OpportunityService.refreshForAccount(a.Id);
}
}
public class OpportunityService {
public static void refreshForAccount(Id accountId) {
for (Opportunity o : [SELECT Id FROM Opportunity WHERE AccountId = :accountId]) {
AccountService.rollupOpportunities(accountId); // Cycles
}
}
}
The chain AccountService -> OpportunityService -> AccountService -> ... repeats until the limit fires.
A third shape: a trigger on Account that updates parent Account records:
trigger AccountRecalc on Account (after update) {
for (Account a : Trigger.new) {
if (a.ParentId != null) {
update new Account(Id=a.ParentId, Owner_Refreshed__c=true);
}
}
}
If parent and child are both in the trigger context, updating the parent fires the trigger again on the parent, which has its own parent, which fires again. The stack depth grows with the parent chain.
The fix, three paths
Guard against re-entry with a static flag. The classic pattern uses a static boolean to track whether the trigger has already run in this transaction:
public class TriggerControl {
public static Boolean isRunning = false;
}
trigger OpportunityRecalc on Opportunity (after update) {
if (TriggerControl.isRunning) return;
TriggerControl.isRunning = true;
try {
// ... do work ...
update toUpdate;
} finally {
TriggerControl.isRunning = false;
}
}
The flag prevents the recursive entry. On the second pass through the trigger, the flag is true, the trigger returns immediately, and the recursion stops.
Avoid the recursive call by restructuring. Instead of issuing updates that fire the trigger again, modify the records in the trigger context. For after update, you can't change Trigger.new in place, but you can shape your logic so subsequent updates aren't needed.
trigger OpportunityRecalc on Opportunity (before update) {
for (Opportunity o : Trigger.new) {
o.Amount = o.Amount * 1.0;
}
// No update statement; the before-update modifies in place
}
The before update trigger modifies Trigger.new directly. The platform persists the modifications without re-firing the trigger. The recursion is gone.
Convert deep recursion to iteration. For tree-walking algorithms (categories with subcategories, accounts with parent accounts, tasks with subtasks), an iterative approach uses a queue or stack data structure instead of method recursion.
public static void walkAccountHierarchy(Id rootId) {
Set<Id> toProcess = new Set<Id>{rootId};
Set<Id> visited = new Set<Id>();
while (!toProcess.isEmpty()) {
Id current = toProcess.iterator().next();
toProcess.remove(current);
visited.add(current);
// Process current
for (Account child : [SELECT Id FROM Account WHERE ParentId = :current]) {
if (!visited.contains(child.Id)) {
toProcess.add(child.Id);
}
}
}
}
The iteration uses a Set as a work queue. Each iteration pops one item and may add more. The call stack stays shallow.
The fixed example
A trigger handler with a recursion guard:
public class OpportunityTriggerHandler {
private static Boolean hasRun = false;
public static void afterUpdate(List<Opportunity> newRecords, Map<Id, Opportunity> oldMap) {
if (hasRun) return;
hasRun = true;
try {
List<Opportunity> toUpdate = new List<Opportunity>();
for (Opportunity o : newRecords) {
if (needsRecalc(o, oldMap.get(o.Id))) {
Opportunity refreshed = new Opportunity(Id=o.Id, Calculated_Field__c=compute(o));
toUpdate.add(refreshed);
}
}
if (!toUpdate.isEmpty()) {
update toUpdate;
}
} finally {
hasRun = false;
}
}
private static Boolean needsRecalc(Opportunity newRec, Opportunity oldRec) {
return newRec.Amount != oldRec.Amount || newRec.StageName != oldRec.StageName;
}
private static Decimal compute(Opportunity o) {
return o.Amount * 1.05;
}
}
trigger OpportunityRecalc on Opportunity (after update) {
OpportunityTriggerHandler.afterUpdate(Trigger.new, Trigger.oldMap);
}
The handler defends against re-entry. The needsRecalc check prevents unnecessary updates. The try/finally resets the flag for subsequent transactions (the static variable persists for the lifetime of the transaction; the reset is for safety in case the trigger is fired multiple times within a single API call).
Trigger frameworks and recursion
Mature trigger frameworks (Kevin O'Hara's TriggerHandler, the FFLib Apex Common framework) include built-in recursion control. The framework wraps each handler invocation with a guard and exposes hooks for re-entry detection.
public class OpportunityHandler extends TriggerHandler {
public override void afterUpdate() {
// Framework already checked re-entry; this code runs once per transaction
for (Opportunity o : (List<Opportunity>) Trigger.new) {
// ...
}
}
}
Adopting a framework spreads the recursion-guard pattern across every handler in your org. New triggers inherit the protection without each developer remembering to add it.
Trigger context and DML cascade
Salesforce's trigger context model can compound recursion problems. A trigger on Account that updates Contact (related list) fires the Contact trigger. The Contact trigger might update the Account. The Account trigger fires again. The cycle is multi-hop and harder to spot than a single-object self-update.
The fix is the same (recursion guards) but the guard must be shared across the related handlers. A single static class with per-object flags works:
public class TriggerControl {
public static Boolean accountRunning = false;
public static Boolean contactRunning = false;
}
Each handler checks its flag, sets it on entry, clears it on exit.
Asynchronous boundaries reset the stack
Future methods, queueable jobs, and batch executes start fresh stacks. A recursion that hits the depth limit in one transaction doesn't persist into the async execution. This is occasionally useful for breaking deep work into chunks: enqueue a job that does part of the work, then enqueues another job for the next chunk. Each job has its own stack budget.
The trade-off is that async jobs have their own governor limits (50 future calls per transaction, queueable chaining limits, batch retry semantics). Picking the right async boundary requires knowing those limits.
Edge case: legitimate deep recursion
Some algorithms are genuinely recursive: tree traversal, certain mathematical computations, parser implementations. For these, the answer isn't a guard; it's restructuring.
The iterative pattern shown above (using a Set or List as a work queue) handles tree traversal in constant stack depth, no matter how deep the tree is. Most recursive algorithms have an iterative equivalent.
For algorithms that are genuinely hard to convert (some parser combinators, certain functional patterns), reconsider whether the algorithm belongs in Apex at all. A complex parsing task might be better suited to an external service called via callout, returning the parsed result for Apex to handle.
Edge case: governor limits other than stack depth
Hitting the stack-depth limit often correlates with hitting other limits. A trigger that recurses for 999 frames also consumes 999 SOQL queries, 999 DML statements, or 999 of various other governor budgets. Fix the recursion, and you fix the cascade of secondary limit errors.
Diagnosing in production
When the LimitException fires:
- Read the stack trace. The bottom frame is the entry point; the top is where the limit hit.
- Look for repeating frames. The repeating pattern is the cycle.
- Identify the recursive call.
- Choose a fix: recursion guard, restructure, or convert to iteration.
A debug log with method-entry instrumentation makes this easier:
System.debug('Entering MyHandler.process for record: ' + recordId);
Repeated debug lines for the same record reveal the cycle in the log.
Test patterns
A test that intentionally exercises the recursion guard:
@isTest
static void testTriggerDoesNotRecurseOnSelfUpdate() {
Opportunity opp = new Opportunity(Name='Test', StageName='Prospecting', CloseDate=Date.today(), Amount=1000);
insert opp;
Test.startTest();
opp.Amount = 2000;
update opp;
Test.stopTest();
Opportunity reloaded = [SELECT Id, Amount FROM Opportunity WHERE Id = :opp.Id];
System.assertEquals(2000, reloaded.Amount, 'Update should complete without recursion');
}
The test confirms the trigger runs but doesn't cycle. Regression tests should cover both the normal path and any edge cases that might invoke the recursive pattern (like updating a related record).
A subtle case: process-builder and flow cascades
Even when your Apex triggers are well-guarded, a Process Builder or record-triggered flow on the same object can re-enter the trigger from a different side. The flow updates a field, which fires the after-update trigger, which calls a service that updates a related record, which fires another flow, and so on. The Apex recursion guard alone doesn't see the flow re-entry, so the stack still grows.
The right defense is a guard that scopes per record, not per trigger run. A Map<Id, Integer> keyed by record Id with a depth counter prevents the same record from cycling through the same handler more than a fixed number of times:
public class TriggerControl {
public static Map<Id, Integer> depthByRecord = new Map<Id, Integer>();
public static final Integer MAX_DEPTH = 3;
}
The handler increments the counter on entry and refuses entry once any record reaches MAX_DEPTH. The pattern catches multi-hop cycles that a plain boolean flag misses.
Async chaining and stack budget
A queueable job that enqueues another queueable can chain indefinitely in production, but each link in the chain starts with a fresh 1,000-frame stack budget. The pattern is useful for genuinely long-running work that can't fit in a single transaction.
public class TreeWalkerJob implements Queueable {
private List<Id> remaining;
public TreeWalkerJob(List<Id> ids) { this.remaining = ids; }
public void execute(QueueableContext qc) {
Integer chunkSize = 100;
for (Integer i = 0; i < chunkSize && !remaining.isEmpty(); i++) {
Id current = remaining.remove(0);
processRecord(current);
}
if (!remaining.isEmpty()) {
System.enqueueJob(new TreeWalkerJob(remaining));
}
}
}
Each enqueued job processes a small chunk and re-enqueues the rest. The stack stays shallow within each job; the work makes forward progress over many transactions. Apex caps queueable chaining depth in production, so plan for the limit (5 chained queueables per transaction by default, increasable via support).
Defensive habits
Always pair a self-modifying trigger with a recursion guard. Even if the current logic doesn't cycle, a future refactor might add the update statement that does. Treat the guard as part of the trigger's contract.
Prefer before triggers when the modification is to the record itself. The before context modifies Trigger.new in place without firing another trigger pass. The platform also handles the persistence; you don't need an explicit update statement.
Adopt a trigger framework early. Frameworks bake in recursion guards, single-trigger-per-object discipline, and other patterns that prevent classes of bugs. Migrating to a framework after an org has dozens of ad-hoc triggers is painful; starting with one from day one is cheap.
Test cross-object trigger cascades explicitly. A test that updates an Account and asserts the Contact, Opportunity, and Case triggers all behave correctly catches multi-hop recursion that single-object tests miss. Cross-object cascades are where the worst stack-depth incidents originate.
Read the debug log carefully. The first time a recursion-related exception fires in production, the log contains every method invocation. Save a copy, annotate the frames, and use the analysis to inform the fix. The same pattern often recurs in other parts of the codebase, and one good investigation pays off many times.
Quick recovery checklist
When the LimitException fires:
- Read the stack trace; find the repeating frames.
- Identify the cycle (self-update, cross-class call, parent-child cascade).
- Add a recursion guard or restructure to break the cycle.
- Add a regression test that reproduces the original failure path.
- Deploy the fix and verify.
Most stack-depth incidents resolve in an hour or two. The longer ones reveal architectural issues with how the codebase handles cascading updates, and those are worth a focused refactor.
Further reading from Salesforce
Related dictionary terms
Share this fix
Related Apex errors
Initial term of field expression must be a concrete SObject: <type>
ApexYou wrote `someThing.Field__c` where `someThing` isn't a specific SObject type — usually an `SObject` or `Object` reference. Apex needs the …
Method does not exist or incorrect signature
ApexThe Apex compiler can't find a method matching the call you wrote — wrong name, wrong argument types, or wrong number of arguments. The comp…
MIXED_DML_OPERATION: DML operation on setup object is not allowed after you have updated a non-setup object (or vice versa)
ApexSalesforce splits objects into "setup" (User, Group, GroupMember, PermissionSet, Profile, Queue, etc.) and "non-setup" (everything else). A …
System.AsyncException: Future method cannot be called from a future or batch method
ApexYou called an `@future` method from inside another `@future`, batch, or queueable. Salesforce blocks recursive async chains because they cou…
System.CalloutException: Read timed out
ApexThe HTTP callout exceeded its allowed read time waiting for the remote server's response. Salesforce caps a single callout at 120 seconds (d…