Order of Execution in Salesforce: What Runs When
Why This Matters
Most trigger bugs aren't logic bugs. They're sequencing bugs — code that's correct in isolation but behaves unexpectedly because the developer didn't know when in the transaction their code runs. A few specific surprises come up again and again:
- An after trigger queries a rollup summary field and gets a stale value — the rollup hasn't recalculated yet.
- A workflow rule's field update causes triggers to fire a second time, doubling the effect of code that wasn't written to run twice.
- Two triggers on the same object interact in non-deterministic ways because there's no guaranteed ordering between them.
- A trigger updates a record inside itself, causing a recursive loop that hits governor limits.
Every one of these problems has the same root cause: not knowing the order of execution. Learn it once, and you'll stop being surprised by any of them.
The Complete Sequence
When a record save operation fires — insert, update, upsert, merge, delete, or undelete — Salesforce processes the following steps in order. This applies to both single-record and bulk operations.
-
Load the original record.
For updates, Salesforce fetches the existing record from the database and merges incoming field values on top.
For inserts, a new record object is initialised. This is the state available in
Trigger.old. - Load and validate the new values. Any field values passed in (from a UI save, API call, or DML statement) are loaded onto the record object.
- Execute system validation rules. Required fields, field format validation (date formats, email formats), and field-type constraints are checked before any Apex runs. If these fail, an error is returned immediately and execution stops.
-
Run all before triggers.
All
before insertorbefore updatetriggers on the object fire. This is the correct place to modify field values — the record is not yet saved to the database, so you can changeTrigger.newvalues directly without any DML. Changes are free (no DML limits consumed). - Re-run system and custom validations. After before triggers have had the opportunity to modify fields, Salesforce validates again. This pass includes: custom validation rules, required field enforcement, unique field constraints, duplicate rules, and foreign-key integrity. If any validation fails here, the transaction rolls back.
-
Save the record to the database (not yet committed).
The record is written to the database in the current transaction. It is not yet committed — the transaction
is still open. At this point, the record has an
Id(if it's an insert). -
Run all after triggers.
All
after insertorafter updatetriggers fire. The record now exists in the database and has anId. However, it is not yet committed. To modify the saved record from here, you must perform a separate DML update — that consumes DML limits and will re-enter the order of execution for that update. - Execute assignment rules. For Lead and Case objects only. Assignment rules evaluate and assign owners.
- Execute auto-response rules. For Lead and Case objects only. Email auto-responses are queued.
- Execute workflow rules. All active workflow rules whose criteria are met are evaluated. Field update actions from workflow rules are collected for application.
- If workflow rules produced field updates: re-fire before and after triggers. This is one of the most important steps to understand. If any workflow rule triggered a field update, Salesforce applies those field updates to the record and then fires before triggers and after triggers one additional time. Standard field validations are also re-evaluated. This second pass of triggers does not cause workflow rules to re-evaluate again — that loop is capped at one additional pass.
- Execute Processes (Process Builder — legacy). Salesforce Process Builder automations run at this point. In modern orgs, these are being migrated to Flows.
- Execute escalation rules. For Case objects only.
- Execute entitlement rules. For Case objects only.
- Execute rollup summary field recalculations. If the saved record is a detail record in a master-detail relationship, any rollup summary fields on the master record are recalculated. If this changes the master record, the master record's triggers fire (its own full order of execution runs as a nested transaction).
- Execute cross-object workflow field updates. If any workflow rules triggered field updates on related (parent) records, those are applied.
- Evaluate criteria-based sharing rules. Salesforce re-evaluates sharing rules to determine if the record's visibility has changed as a result of the save.
- Commit the transaction to the database. All changes are committed. The record is now permanently written. Any failure before this point rolls back the entire transaction.
- Execute post-commit logic. After the database commit, Salesforce processes actions that require the commit to have succeeded: sending workflow email alerts, publishing Platform Events, enqueuing async operations (Queueable Apex, future methods, Batch Apex), and other post-commit integrations.
Critical Implications for Developers
Rollup Summary Fields Are Stale in After Triggers
When your after trigger queries the master record to read a rollup summary field, that rollup has not yet been recalculated for the current transaction (step 15 comes after step 7). You'll get the value from before this save occurred. Never make business decisions in a trigger based on rollup field values from the master — they will be wrong for the current state.
If you need the current aggregate value, compute it yourself in the trigger using the Trigger.new
and Trigger.old collections, or query the child records directly.
Workflow Field Updates Re-Fire Triggers
Step 11 means your trigger will run at least twice in any transaction where a workflow rule fires a field update on the same object. If your trigger creates records or sends external callouts, the doubled execution has real consequences. Design your triggers to be idempotent — running them twice should produce the same result as running them once.
Before vs After: Where to Put Your Logic
-
Before triggers are for modifying field values on the record being saved.
The record is not yet committed, so you assign to
Trigger.new[i].FieldName__cdirectly. No DML statement needed. No DML limits consumed. -
After triggers are for operations that depend on the record having an
Id(creating related records, sending notifications, updating related objects). To modify the triggering record itself from an after trigger, you must perform an explicitupdateDML — which costs DML operations and re-enters the order of execution.
Multiple Triggers on the Same Object: Order Is Not Guaranteed
Salesforce makes no guarantee about the execution order of multiple triggers defined on the same object for
the same event. If you have AccountTrigger and AccountAuditTrigger both listening
to before update, either may run first. The only reliable approach is to consolidate all trigger
logic for a given object into a single trigger that delegates to a handler class.
Recursive Triggers
A recursive trigger is one that indirectly causes itself to fire again. The most common scenario:
- An
after updatetrigger onOpportunityruns. - Inside the trigger, your code performs a DML
updateon one of the sameOpportunityrecords. - That DML update re-enters the order of execution for
Opportunity, which fires the after trigger again. - The trigger runs again, performs the DML again, fires again — until Salesforce hits the recursion limit and throws a runtime exception.
The same pattern occurs indirectly: trigger A updates Account, Account's trigger updates a related Contact, Contact's trigger updates Account again, re-firing trigger A.
The standard prevention pattern uses a static variable in a helper class. Because static variables persist for the lifetime of a transaction (not across transactions), they serve as a transaction-scoped flag.
The Static Flag Pattern
The simplest version uses a static Boolean to block re-entry:
public class OpportunityTriggerHandler {
private static Boolean isRunning = false;
public static void handleAfterUpdate(List<Opportunity> newList, Map<Id, Opportunity> oldMap) {
if (isRunning) {
return;
}
isRunning = true;
try {
List<Opportunity> toUpdate = new List<Opportunity>();
for (Opportunity opp : newList) {
Opportunity old = oldMap.get(opp.Id);
if (opp.StageName != old.StageName && opp.StageName == 'Closed Won') {
toUpdate.add(new Opportunity(
Id = opp.Id,
Description = 'Stage closed at: ' + System.now().format()
));
}
}
if (!toUpdate.isEmpty()) {
update toUpdate;
}
} finally {
isRunning = false;
}
}
}
The finally block resets the flag even if an exception is thrown, which prevents the flag from
permanently blocking subsequent legitimate trigger invocations within the same transaction.
The Set<Id> Pattern for Finer Control
The Boolean flag blocks all re-entry. In bulk operations, this can cause problems: if a batch of 200 records
triggers an update that includes some new records not in the original set, the Boolean flag blocks those too.
A Set<Id> of already-processed record IDs gives you per-record control:
public class OpportunityTriggerHandler {
private static Set<Id> processedIds = new Set<Id>();
public static void handleAfterUpdate(List<Opportunity> newList, Map<Id, Opportunity> oldMap) {
List<Opportunity> unprocessed = new List<Opportunity>();
for (Opportunity opp : newList) {
if (!processedIds.contains(opp.Id)) {
unprocessed.add(opp);
}
}
if (unprocessed.isEmpty()) {
return;
}
// Mark as processed before DML to block re-entry
for (Opportunity opp : unprocessed) {
processedIds.add(opp.Id);
}
List<Opportunity> toUpdate = new List<Opportunity>();
for (Opportunity opp : unprocessed) {
Opportunity old = oldMap.get(opp.Id);
if (opp.StageName != old.StageName && opp.StageName == 'Closed Won') {
toUpdate.add(new Opportunity(
Id = opp.Id,
Description = 'Stage closed at: ' + System.now().format()
));
}
}
if (!toUpdate.isEmpty()) {
update toUpdate;
}
}
}
The Trigger and Handler Together
The trigger itself should contain no logic — just routing. All logic lives in the handler class:
trigger OpportunityTrigger on Opportunity (
before insert, before update,
after insert, after update, after delete
) {
if (Trigger.isAfter && Trigger.isUpdate) {
OpportunityTriggerHandler.handleAfterUpdate(Trigger.new, Trigger.oldMap);
}
if (Trigger.isBefore && Trigger.isUpdate) {
OpportunityTriggerHandler.handleBeforeUpdate(Trigger.new, Trigger.oldMap);
}
if (Trigger.isBefore && Trigger.isInsert) {
OpportunityTriggerHandler.handleBeforeInsert(Trigger.new);
}
}
Testing Recursion Prevention
Write an explicit test that verifies the handler doesn't run twice for a record it has already processed:
@IsTest
private class OpportunityTriggerHandlerTest {
@TestSetup
static void setup() {
Account acc = new Account(Name = 'Test Account');
insert acc;
Opportunity opp = new Opportunity(
Name = 'Test Opp',
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
);
insert opp;
}
@IsTest
static void whenStageClosedWon_descriptionIsSet_andNotDoubled() {
Opportunity opp = [SELECT Id, StageName FROM Opportunity LIMIT 1];
Test.startTest();
opp.StageName = 'Closed Won';
update opp;
Test.stopTest();
Opportunity result = [SELECT Id, Description FROM Opportunity WHERE Id = :opp.Id];
// Description should be set exactly once, not duplicated
System.assertNotEquals(null, result.Description,
'Description should be set when stage moves to Closed Won');
// If the handler ran twice, the description would be set twice (appended or overwritten)
// Verify it doesn't contain duplicated timestamp markers
System.assert(
result.Description.countMatches('Stage closed at:') == 1,
'Handler ran more than once: ' + result.Description
);
}
@IsTest
static void staticSetIsTransactionScoped_resetsBetweenTests() {
// Static variables reset between test method invocations in separate transactions.
// This test confirms a fresh transaction sees an empty processedIds set.
Opportunity opp = [SELECT Id, StageName FROM Opportunity LIMIT 1];
Test.startTest();
opp.StageName = 'Closed Won';
update opp;
Test.stopTest();
Opportunity result = [SELECT Id, Description FROM Opportunity WHERE Id = :opp.Id];
System.assertNotEquals(null, result.Description);
}
}
Quick Reference: The Sequence on One Page
| Step | What Runs | Notes |
|---|---|---|
| 1 | Load original record | Trigger.old is populated |
| 2 | Merge new values | Trigger.new is populated |
| 3 | System validations | Required fields, type checks — fail fast |
| 4 | Before triggers | Modify Trigger.new freely; no DML needed |
| 5 | Custom validations & duplicate rules | Post-trigger field values are validated |
| 6 | Save to database (not committed) | Record gets an Id on insert |
| 7 | After triggers | Id available; DML to change triggering record costs limits |
| 8–9 | Assignment & auto-response rules | Leads and Cases only |
| 10 | Workflow rules | Field updates collected |
| 11 | Re-fire before + after triggers | Only if workflow field updates occurred |
| 12 | Process Builder | Legacy; being replaced by Flows |
| 13–14 | Escalation & entitlement rules | Cases only |
| 15 | Rollup recalculations | May fire master record's triggers |
| 16 | Cross-object workflow updates | |
| 17 | Sharing rules evaluation | |
| 18 | Database commit | All-or-nothing; failure here rolls back everything |
| 19 | Post-commit logic | Emails, Platform Events, async Apex enqueued |
The order of execution is the foundation of reliable Salesforce development. Know that before triggers let you
modify fields for free while after triggers require a DML update to do the same. Know that rollup summary fields
are calculated after after triggers run, so never rely on them in the same transaction. Know that workflow field
updates cause a second pass of triggers — write every trigger as if it might run twice. For recursion, use a
static Set<Id> in a handler class to gate which records your trigger processes, and consolidate
all logic for one object into one trigger to keep execution predictable.
Frequently Asked Questions
Why does an after trigger return a stale value when querying a rollup summary field?
Rollup summary fields on parent records are recalculated after the transaction commits, not during it. When an after trigger queries a parent's rollup field, the child record has been saved to the database but the transaction is still open, so the parent's aggregate value hasn't updated yet. To work around this, use asynchronous Apex (a future method or queueable) to query the rollup after the transaction closes.
When a workflow field update causes triggers to fire a second time, which steps in the order of execution are repeated?
After workflow rules and field updates execute, Salesforce re-runs the entire trigger sequence from the beginning — before triggers, validations, the database save, and after triggers all fire again on the same record. This means any trigger that isn't written with a static flag or recursion guard will execute its logic twice, which commonly causes doubled DML operations or incorrect field calculations.
Is there a guaranteed execution order when multiple triggers exist on the same object?
No — Salesforce does not guarantee the order in which multiple triggers on the same object fire. The execution order is non-deterministic and can change between deployments or org refreshes. The standard solution is to maintain a single trigger per object that delegates to an Apex handler class, giving full control over the sequence of logic within that one trigger.
At what point in the order of execution does a newly inserted record first have an Id available?
The record receives its Id when it is saved to the database, which occurs after all before triggers and the second round of validations have completed. This means before triggers cannot reference Trigger.new[i].Id on an insert — it will be null. After triggers are the earliest point where the Id is accessible and can safely be used in queries or related record operations.