In this guide
1 What is an Apex Trigger?
An Apex Trigger is a block of Apex code that executes automatically in response to a specific data manipulation language (DML) event on a Salesforce object — such as inserting, updating, deleting, or undeleting records. Triggers let you run server-side business logic that fires during the record save process, before or after data is committed to the database.
The basic syntax for declaring a trigger is straightforward:
trigger AccountTrigger on Account (before insert, after insert, before update, after update) {
// Your logic here. Salesforce injects context variables automatically.
// Trigger.new — list of new/updated records
// Trigger.old — list of previous record values (update/delete only)
}
When should you use a trigger?
Triggers are powerful but should be a last resort — always prefer declarative tools (Flow, Validation Rules, Rollup Summary fields) when they can do the job. Use a trigger when you need to:
- Perform complex multi-object DML that a Flow cannot handle cleanly — for example, creating child records across multiple unrelated objects in a single transaction.
- Make a callout to an external system using a future method or queueable Apex (callouts cannot run directly in before/after triggers, but can be enqueued).
- Conditionally bypass automation based on runtime state that is not expressible in Flow criteria.
- Process collections of records efficiently in bulk operations where the logic requires set-based comparisons or Map lookups that are unnatural in Flow.
- Enforce complex cross-record validation involving aggregated SOQL queries that must run in the same transaction as the save.
2 Trigger Events Reference Table
Every trigger declaration lists one or more trigger events — a combination of timing (before/after) and operation (insert/update/delete/undelete). Understanding when each event fires and what it is suited for is the foundation of writing correct triggers.
| Event | Timing | Record has ID? | Can modify Trigger.new? | Typical use case |
|---|---|---|---|---|
before insert |
Before save | No | Yes — no extra DML needed | Defaulting field values, validation before record is saved |
after insert |
After save | Yes | No (read-only) | Creating related records, sending emails, posting to Chatter |
before update |
Before save | Yes | Yes — no extra DML needed | Recalculating derived fields, cross-field validation on changes |
after update |
After save | Yes | No (read-only) | Updating related records when a parent field changes |
before delete |
Before delete | Yes | N/A — Trigger.old only | Preventing deletion of records that meet certain criteria |
after delete |
After delete | Yes (in Trigger.old) | N/A — Trigger.old only | Cleaning up orphaned related records after deletion |
after undelete |
After restore | Yes | No (read-only) | Re-establishing relationships or re-creating data after record is restored from the Recycle Bin |
before trigger you can set fields directly on records in Trigger.new and those changes are saved automatically. In an after trigger, Trigger.new is read-only — you must perform an explicit DML update if you need to change the same records.3 Trigger Context Variables
Salesforce automatically populates a set of context variables inside every trigger. These variables expose the records being processed and metadata about the current operation. You never need to instantiate them — they are always available within trigger scope.
| Variable | Type | Available in | Description |
|---|---|---|---|
Trigger.new |
List<SObject> |
insert, update, undelete | The new versions of the records being saved. Writable in before triggers. |
Trigger.old |
List<SObject> |
update, delete | The previous (pre-change) versions of the records. Always read-only. |
Trigger.newMap |
Map<Id, SObject> |
after insert, update, undelete | A map of record IDs to new record versions. Essential for efficient lookups. |
Trigger.oldMap |
Map<Id, SObject> |
update, delete | A map of record IDs to old record versions. Use for change-detection comparisons. |
Trigger.isInsert |
Boolean |
All events | true when the triggering operation is an insert. |
Trigger.isUpdate |
Boolean |
All events | true when the triggering operation is an update. |
Trigger.isDelete |
Boolean |
All events | true when the triggering operation is a delete. |
Trigger.isUndelete |
Boolean |
All events | true when the triggering operation is an undelete from the Recycle Bin. |
Trigger.isBefore |
Boolean |
All events | true when the trigger fires before the database operation. |
Trigger.isAfter |
Boolean |
All events | true when the trigger fires after the database operation. |
Trigger.size |
Integer |
All events | Total number of records in the current trigger invocation (max 200). |
Trigger.operationType |
System.TriggerOperation |
All events | An enum value such as BEFORE_INSERT, AFTER_UPDATE, etc. Useful with switch statements. |
Using Trigger.oldMap for change detection
A common pattern when handling updates is to check whether a specific field actually changed — rather than running logic every time the record is saved for any reason. Trigger.oldMap makes this efficient:
trigger OpportunityTrigger on Opportunity (after update) {
List<Opportunity> stageChanged = new List<Opportunity>();
for (Opportunity opp : Trigger.new) {
Opportunity oldOpp = Trigger.oldMap.get(opp.Id);
if (opp.StageName != oldOpp.StageName) {
stageChanged.add(opp);
}
}
if (!stageChanged.isEmpty()) {
OpportunityHandler.handleStageChange(stageChanged);
}
}
4 Bulkification — Why It Matters
Triggers do not fire once per record — they fire once per batch of up to 200 records. This happens whenever records are saved via Data Loader, the Bulk API, or even a single update that touches many records at once. Every trigger you write must be designed to handle 200 records in a single invocation efficiently and correctly.
Salesforce enforces governor limits per transaction to prevent any single piece of code from monopolising the shared multitenant infrastructure. The limits most relevant to triggers are:
If you place a SOQL query or a DML statement inside a loop over records, you will hit these limits the moment a batch of records is processed. A batch of just 101 records will breach the 100 SOQL query limit if there is one query per record. This causes an uncatchable LimitException and rolls back the entire transaction.
Incorrect: SOQL and DML inside a loop
trigger ContactTrigger on Contact (after insert) {
for (Contact c : Trigger.new) {
// DANGER: 1 SOQL query fired for every single record in the batch!
Account acc = [SELECT Id, Name FROM Account WHERE Id = :c.AccountId];
Task t = new Task(
Subject = 'Follow up with ' + acc.Name,
WhoId = c.Id,
ActivityDate = Date.today().addDays(7)
);
// DANGER: 1 DML statement fired for every single record in the batch!
insert t;
}
}
Correct: collect IDs first, query once, DML once
trigger ContactTrigger on Contact (after insert) {
// Step 1: collect all Account IDs from this batch
Set<Id> accountIds = new Set<Id>();
for (Contact c : Trigger.new) {
if (c.AccountId != null) accountIds.add(c.AccountId);
}
// Step 2: ONE SOQL query for all needed Accounts
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);
// Step 3: build all Task records in memory
List<Task> tasksToInsert = new List<Task>();
for (Contact c : Trigger.new) {
if (c.AccountId == null) continue;
Account acc = accountMap.get(c.AccountId);
tasksToInsert.add(new Task(
Subject = 'Follow up with ' + acc.Name,
WhoId = c.Id,
ActivityDate = Date.today().addDays(7)
));
}
// Step 4: ONE DML statement for all Tasks
if (!tasksToInsert.isEmpty()) insert tasksToInsert;
}
The bulkified version uses exactly 1 SOQL query and 1 DML statement regardless of whether the trigger fires for 1 record or 200 records. This is the fundamental rule of bulkification: move queries and DML outside loops, operate on collections.
IN clause on that Set. (3) Load results into a Map for O(1) lookup inside the loop. (4) Build a List of records to insert/update inside the loop. (5) Run one DML call on the List after the loop.5 Trigger Handler Pattern
Writing all your business logic directly inside the trigger file is an anti-pattern. Triggers are not unit-testable in isolation, they cannot be reused, and they quickly become unmaintainable. The industry-standard solution is the Trigger Handler Pattern: keep the trigger thin and delegate all logic to a dedicated Apex class.
This approach gives you several benefits:
- Handler methods are plain Apex and can be tested directly without going through DML.
- Logic is organised into clearly named methods, making intent obvious.
- The same handler can be called by multiple triggers or from anonymous Apex during debugging.
- You can add a bypass mechanism (a custom setting or static flag) to the handler without touching the trigger file.
The trigger — thin and clean
trigger AccountTrigger on Account (
before insert, after insert,
before update, after update,
before delete, after delete
) {
AccountTriggerHandler handler = new AccountTriggerHandler();
switch on Trigger.operationType {
when BEFORE_INSERT { handler.beforeInsert(Trigger.new); }
when AFTER_INSERT { handler.afterInsert(Trigger.newMap); }
when BEFORE_UPDATE { handler.beforeUpdate(Trigger.new, Trigger.oldMap); }
when AFTER_UPDATE { handler.afterUpdate(Trigger.newMap, Trigger.oldMap); }
when BEFORE_DELETE { handler.beforeDelete(Trigger.old); }
when AFTER_DELETE { handler.afterDelete(Trigger.oldMap); }
}
}
The handler class — where logic lives
public with sharing class AccountTriggerHandler {
public void beforeInsert(List<Account> newAccounts) {
// Set default values, run validations — no extra DML needed
for (Account acc : newAccounts) {
if (String.isBlank(acc.Description)) {
acc.Description = 'Auto-generated: ' + Date.today();
}
}
}
public void afterInsert(Map<Id, Account> newMap) {
// Create related records, send notifications, etc.
AccountService.createDefaultContacts(newMap.keySet());
}
public void beforeUpdate(List<Account> newList, Map<Id, Account> oldMap) {
for (Account acc : newList) {
if (acc.AnnualRevenue != oldMap.get(acc.Id).AnnualRevenue) {
AccountService.recalculateTier(acc);
}
}
}
public void afterUpdate(Map<Id, Account> newMap, Map<Id, Account> oldMap) {
AccountService.syncChildOpportunities(newMap, oldMap);
}
public void beforeDelete(List<Account> oldList) {
for (Account acc : oldList) {
if (acc.Type == 'Strategic') {
acc.addError('Strategic accounts cannot be deleted.');
}
}
}
public void afterDelete(Map<Id, Account> oldMap) {
AccountService.archiveRelatedData(oldMap.keySet());
}
}
Notice that AccountService (not shown) contains the actual business logic, separated further into a service layer. For small projects a single handler class is sufficient, but large teams often adopt a three-layer structure: Trigger → Handler → Service.
6 Recursive Trigger Guard
A recursive trigger occurs when a trigger performs a DML operation on the same object that fired it, causing the trigger to fire again — and again, until a governor limit is breached. For example, an after-update trigger on Account that updates an Account field will fire itself again on every batch of records it touches.
The classic fix is a static Boolean flag in a utility class. Because static variables persist for the lifetime of a transaction in Apex, you can check and set the flag to ensure a block of trigger logic runs only once per transaction.
public class TriggerGuard {
// Static variables persist for the entire Apex transaction.
private static Map<String, Boolean> runOnceMap = new Map<String, Boolean>();
public static Boolean hasRun(String context) {
return runOnceMap.containsKey(context) && runOnceMap.get(context);
}
public static void setHasRun(String context, Boolean value) {
runOnceMap.put(context, value);
}
}
public void afterUpdate(Map<Id, Account> newMap, Map<Id, Account> oldMap) {
// Guard prevents this block from running more than once per transaction
if (TriggerGuard.hasRun('AccountTriggerHandler.afterUpdate')) return;
TriggerGuard.setHasRun('AccountTriggerHandler.afterUpdate', true);
// Safe to run now — will not cause infinite recursion
AccountService.syncChildOpportunities(newMap, oldMap);
}
Simpler single-trigger variant
For a single trigger with no need for context keys, a simple static Boolean suffices:
public with sharing class AccountTriggerHandler {
private static Boolean alreadyRan = false;
public void afterUpdate(Map<Id, Account> newMap, Map<Id, Account> oldMap) {
if (alreadyRan) return;
alreadyRan = true;
// ... logic ...
}
}
7 Common Trigger Mistakes
Even experienced developers make the following mistakes when working with Apex Triggers. Knowing them in advance will save you hours of debugging in production.
DML Inside a Loop
Placing insert, update, or delete inside a for loop over records will hit the 150 DML limit the moment the batch exceeds 150 records. Always collect records into a List and run DML once after the loop.
SOQL Inside a Loop
A SOQL query inside a loop fires one query per record. With 101 records you exceed the 100 SOQL limit and get a LimitException. Collect IDs into a Set, query once with an IN clause, and use a Map for lookups.
No Trigger Handler
Putting all logic directly in the trigger file makes the code untestable in isolation and quickly becomes a maintenance nightmare with hundreds of lines in a single file. Always delegate to a handler class from day one.
Multiple Triggers on One Object
When two or more triggers exist on the same object, their execution order is non-deterministic. This makes it impossible to guarantee which trigger runs first, leading to hard-to-reproduce bugs. Keep exactly one trigger per object.
Modifying Trigger.new in After Triggers
In an after trigger, Trigger.new is read-only. Attempting to set fields on these records will throw a runtime error. If you need to update the same records in an after trigger, perform an explicit DML update on a separate list.
Callouts Directly in Triggers
HTTP callouts cannot be made directly from a trigger (synchronous context after DML has started). Use a @future(callout=true) method or a Queueable Apex class to make callouts asynchronously after the trigger completes.
Hardcoded IDs
Hardcoding Salesforce record IDs (e.g., record type IDs, user IDs, price book IDs) in trigger code is fragile — IDs differ between sandboxes and production. Use SOQL to look up records by developer name or unique field at runtime.
No Null Checks
Fields in Trigger.new can be null if the user left them blank. Accessing a method on a null field (e.g., c.AccountId.getSObjectType()) throws a NullPointerException. Always guard with != null checks before dereferencing.
Practice Problems on ApexArena
Knowing the theory is only half the battle. Solidify your understanding by solving real coding challenges in your browser — no Salesforce org required.
Practice: Bulk-Safe Account Trigger
Write a trigger that creates a follow-up Task for each Account inserted, using exactly one SOQL query and one DML statement for any batch size.
Start challenge →Practice: Recursive Guard Implementation
Implement a static Boolean guard that prevents an after-update trigger on Contact from firing more than once per transaction, even when the handler performs its own DML.
Start challenge →Practice: Field Defaulting vs Related Record
Given two requirements — one that defaults a field on insert and one that creates a child record after insert — correctly assign each to the right trigger event and write the complete trigger + handler.
Start challenge →Practice: Refactor Trigger to Handler Pattern
You are given a monolithic trigger with all logic inline. Refactor it into a clean Trigger + Handler + Service architecture with no logic remaining in the trigger file itself.
Start challenge →Frequently Asked Questions
trigger keyword and is associated with a specific sObject, for example: trigger AccountTrigger on Account (before insert, after update) { ... }.
Trigger.new and those changes are saved without an additional DML statement. This makes before triggers ideal for setting default field values and running validation. An after trigger fires after the record has been saved and its Id has been assigned. Trigger.new is read-only in an after trigger, but you can now create or update related records because the parent record's Id is available. Use after triggers for creating child records, sending emails, invoking future methods, or any logic that requires the final committed state of the record.
@isTest. Each test method should use Test.startTest() and Test.stopTest() to isolate governor limits, then perform the DML operation (insert/update/delete) that fires the trigger, and finally use System.assertEquals() or System.assertNotEquals() to verify the expected outcome. Salesforce requires at least 75% code coverage across all Apex code — including triggers — before you can deploy to production. A well-structured test covers both the happy path and edge cases such as empty lists, null fields, and maximum bulk sizes (200 records). Always use @TestSetup for test data creation shared across multiple test methods in the same class.
Trigger.new collectively — never placing SOQL queries or DML statements inside a for loop over records. Breaching any governor limit causes an uncatchable System.LimitException that rolls back the entire transaction.