World’s largest virtual agentic engineering & quality conference
Salesforce Apex trigger reference: syntax, the 7 trigger events, context variables, order of execution, governor limits, bulkification, and Apex test classes.

Harshit Paul
Author

Himanshu Sheth
Reviewer
Last Updated on: August 9, 2026
A Salesforce Apex trigger that works perfectly in the Developer Console can fail the moment a data load hits it. The reason is rarely the logic. It is that the trigger was written as if it handles one record, while the platform hands it a list.
Salesforce caps a synchronous transaction at 100 SOQL queries and 150 DML statements, per the Apex governor limits reference. A single query inside a loop crosses that ceiling long before a real import finishes.
This guide is a code-first reference for the parts that decide whether a trigger survives production: the seven events, the context variables and where each one is null, the save order, bulkification, and the test class that proves it. For the wider QA picture around Apex, see the Salesforce testing guide.
Overview
An Apex trigger is Apex code bound to one sObject that Salesforce runs automatically before or after an insert, update, delete, or undelete. It cannot be called directly. Its logic runs against a list of records, so every trigger must be written to handle many records in a single invocation rather than one.
What Decides Whether a Trigger Survives Production?
What Do Apex Tests Not Cover?
Apex tests validate server-side logic only. They never render a page, so a trigger that fires correctly can still break the Lightning UI that depends on it. Teams close that gap with browser-level regression testing on TestMu AI alongside their Apex suite.
An Apex trigger is Apex code attached to a single sObject that the platform executes when a record changes. You never invoke it. Salesforce does, as part of the save pipeline.
Three properties separate a trigger from ordinary Apex, and each one causes a predictable class of bug when it is forgotten:
Reach for a trigger when the logic needs conditional branching, cross-object writes, or access to the previous version of a record. Anything simpler is usually cheaper as a validation rule or a record-triggered flow.
Salesforce documents the declaration as trigger TriggerName on ObjectName (trigger_events) { code_block } in the Apex trigger syntax reference. The events argument accepts a comma-separated list, so one trigger can serve several events.
trigger AccountTrigger on Account (before insert, before update) {
for (Account acct : Trigger.new) {
if (String.isBlank(acct.Rating)) {
acct.Rating = 'Warm';
}
}
}Note what is absent. There is no update statement, because this is a before trigger and the record has not been written yet. Assigning to a field on Trigger.new is enough, and adding a DML call here would be both redundant and a governor limit cost.
In a source-tracked project the trigger is two files: AccountTrigger.trigger and AccountTrigger.trigger-meta.xml. Deploying one without the other fails, and the metadata file is also where you deactivate a trigger later.
There are seven. The trigger context variables documentation lists the System.TriggerOperation enum values as BEFORE_INSERT, BEFORE_UPDATE, BEFORE_DELETE, AFTER_INSERT, AFTER_UPDATE, AFTER_DELETE, and AFTER_UNDELETE, which is the most reliable place to check them.
| Event | Use it when | Watch out for |
|---|---|---|
| before insert | Defaulting or normalizing field values on the incoming record. | Record IDs do not exist yet, so Trigger.newMap is unavailable. |
| before update | Comparing old and new values, then adjusting the record in memory. | Issuing DML on the same object here causes recursion. |
| before delete | Blocking a delete with addError based on related data. | Trigger.new does not exist; you only have Trigger.old. |
| after insert | Creating related records that need the new record ID. | Trigger.new is read-only; writing to it throws at runtime. |
| after update | Rolling values up to a parent or syncing another object. | Fires again after a workflow field update. |
| after delete | Cleaning up or recalculating parents after removal. | Only Trigger.old is populated. |
| after undelete | Restoring derived data when a record returns from the Recycle Bin. | Routinely omitted, which leaves restored records inconsistent. |
Routing on the enum with a switch keeps a multi-event trigger readable and makes a missing event obvious at a glance:
trigger AccountRouter on Account (
before insert, before update, before delete,
after insert, after update, after delete, after undelete
) {
switch on Trigger.operationType {
when BEFORE_INSERT { AccountTriggerHandler.beforeInsert(Trigger.new); }
when BEFORE_UPDATE { AccountTriggerHandler.beforeUpdate(Trigger.new, Trigger.oldMap); }
when BEFORE_DELETE { AccountTriggerHandler.beforeDelete(Trigger.old); }
when AFTER_INSERT { AccountTriggerHandler.afterInsert(Trigger.new); }
when AFTER_UPDATE { AccountTriggerHandler.afterUpdate(Trigger.new, Trigger.oldMap); }
when AFTER_DELETE { AccountTriggerHandler.afterDelete(Trigger.old); }
when AFTER_UNDELETE { AccountTriggerHandler.afterUndelete(Trigger.new); }
}
}Context variables are how a trigger reads its own runtime state. The trap is that an unavailable one returns null instead of raising an error, so the bug surfaces later as a null pointer exception in a different method.
| Variable | What it holds | Availability |
|---|---|---|
| Trigger.new | List of the new versions of the records. | Insert, update, undelete. Writable only in before insert and before update. |
| Trigger.old | List of the previous versions of the records. | Update and delete only. Always read-only. |
| Trigger.newMap | Map of record ID to new record version. | Before update, after insert, after update, after undelete. Not in before insert. |
| Trigger.oldMap | Map of record ID to previous record version. | Update and delete only. |
| Trigger.operationType | Enum identifying the current event. | All events. The cleanest value to branch on. |
| Trigger.size | Number of records in the current invocation. | All events. Counts this invocation, not the whole operation. |
| Trigger.isExecuting | True when the current context is a trigger. | All events. Useful in shared handler code. |
The boolean flags isInsert, isUpdate, isDelete, isUndelete, isBefore, and isAfter are available in every event and predate operationType. They still work, but a chain of nested if statements is harder to audit than a single switch.
This is the most commonly misstated part of the platform, and getting it backwards produces bugs that look impossible. The Salesforce order of execution reference documents the save pipeline as a numbered sequence.
Read step 4 and step 5 together, because the consequence is not intuitive. Custom validation rules run after your before trigger, not before it. A value your trigger writes is still judged by validation, so a trigger can trip a validation rule on data the user never typed, and the error message will point at a field they never touched.
Steps 16 and 17 matter just as often. Roll-up summary fields recalculate long after your Apex has finished, so a trigger that reads a roll-up field is reading the value from before this transaction.
Limits are shared across the whole transaction, not allocated per trigger. Every trigger, handler, and flow that fires in the same save consumes from one budget. These are the per-transaction values from the Salesforce Apex governor limits reference that a trigger realistically hits:
| Limit | Value | How a trigger burns it |
|---|---|---|
| SOQL queries, synchronous | 100 | One query inside a for loop over more than 100 records. |
| SOQL queries, asynchronous | 200 | Doubles only once execution moves to a future or Queueable context. |
| Records retrieved by SOQL | 50,000 | An unfiltered query against a large object. |
| DML statements | 150 | Calling update inside a loop instead of once on a collection. |
| Records processed by DML | 10,000 | Cascading writes that fan out across related records. |
| CPU time, synchronous | 10,000 ms | Nested loops over large collections. |
| Heap size, synchronous | 6 MB | Holding large query results in memory at once. |
| Stack depth, recursive triggers | 16 | A trigger whose DML re-fires itself without a guard. |
You do not have to guess which limit you are approaching. The Limits class reports consumption live, and dropping these three lines at the end of a handler turns an abstract ceiling into a number in your debug log:
System.debug('SOQL: ' + Limits.getQueries() + ' of ' + Limits.getLimitQueries());
System.debug('DML: ' + Limits.getDmlStatements() + ' of ' + Limits.getLimitDmlStatements());
System.debug('CPU: ' + Limits.getCpuTime() + ' ms of ' + Limits.getLimitCpuTime());Run that against 1 record, then against 200, and compare. If the SOQL count scales with the record count rather than staying flat, the trigger is not bulkified and the next section is the fix.
Salesforce states that all triggers are bulk triggers by default and can process multiple records at a time. That is a statement about what the platform hands you, not a promise that your code copes with it.
The examples below reference a custom field on Account named Active_Contacts__c. Create it as Number(18, 0) before deploying any of this code, and make it a plain number field rather than a Roll-Up Summary, since the trigger maintains the value itself. Apex will not compile against a custom field that does not exist as metadata first.
Here is the failure, written the way it usually reaches production. It works flawlessly when a user saves one Contact. The two triggers that follow are alternatives, so deploy one or the other and never both, because two triggers on the same object and event would each count every Contact:
// Anti-pattern: one SOQL query and one DML statement per record.
trigger ContactTriggerNaive on Contact (after insert) {
for (Contact c : Trigger.new) {
Account a = [SELECT Id, Active_Contacts__c FROM Account WHERE Id = :c.AccountId];
a.Active_Contacts__c = (a.Active_Contacts__c == null ? 0 : a.Active_Contacts__c) + 1;
update a;
}
}Insert 101 Contacts that each carry an AccountId and the 101st query throws a System.LimitException. It can fail sooner than that: any Contact with a null AccountId makes the SOQL assignment match no rows, which throws a System.QueryException on the first record. The bulkified version collects the IDs first, queries once, mutates in memory, and writes once:
trigger ContactTrigger on Contact (after insert) {
Set<Id> accountIds = new Set<Id>();
for (Contact c : Trigger.new) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
Map<Id, Account> accounts = new Map<Id, Account>(
[SELECT Id, Active_Contacts__c FROM Account WHERE Id IN :accountIds]
);
for (Contact c : Trigger.new) {
Account a = accounts.get(c.AccountId);
if (a != null) {
a.Active_Contacts__c = (a.Active_Contacts__c == null ? 0 : a.Active_Contacts__c) + 1;
}
}
update accounts.values();
}The query and DML counts are now 1 and 1 regardless of how many Contacts arrive. That flatness is the whole point, and it maps directly onto the two practices Salesforce actually documents on its bulk trigger best practices page: minimize DML by batching records into collections, and minimize SOQL by preprocessing records into sets used with the IN clause.
A trigger that updates its own object re-enters itself. The platform caps that at a stack depth of 16, but hitting the cap is a crash, not a safeguard.
The usual fix is a static boolean, and it has a defect worth understanding. A static variable lives for the whole transaction, so once the first group of records flips the flag, a later group in the same transaction is skipped entirely and silently loses its updates. Tracking IDs instead of a single flag keeps every record processed exactly once:
public class TriggerGuard {
private static Set<Id> processedIds = new Set<Id>();
public static Boolean isFirstRun(Id recordId) {
if (processedIds.contains(recordId)) {
return false;
}
processedIds.add(recordId);
return true;
}
}One re-entry is not a bug and should not be guarded away: a workflow field update legitimately re-runs before and after update triggers. Guard the logic that must not repeat, such as sending a notification or incrementing a counter, rather than blocking the whole trigger.
The one trigger per object rule is usually stated as though the platform enforces it. It does not, and Salesforce's Trigger and Bulk Request Best Practices page documents only two practices, both about minimizing DML and SOQL. Nothing stops you deploying five triggers on Account.
The justification is narrower and more useful than a mandate: when several triggers exist on one object for the same event, their relative execution order is not guaranteed. Logic that depends on ordering becomes non-deterministic and very difficult to reproduce. Consolidating into one trigger that delegates to a handler makes the sequence explicit in code you control.
The handler below assumes one more custom field on Account, Ownership_Changed_On__c, of type Date. System.today() returns a Date rather than a Datetime, so a Date/Time field will not compile against it.
public with sharing class AccountTriggerHandler {
public static void beforeInsert(List<Account> newAccounts) { }
public static void beforeUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) {
for (Account acct : newAccounts) {
Account previous = oldMap.get(acct.Id);
if (acct.OwnerId != previous.OwnerId) {
acct.Ownership_Changed_On__c = System.today();
}
}
}
public static void beforeDelete(List<Account> oldAccounts) {
Set<Id> acctIds = new Set<Id>();
for (Account a : oldAccounts) {
acctIds.add(a.Id);
}
Map<Id, Account> withChildren = new Map<Id, Account>(
[SELECT Id FROM Account
WHERE Id IN :acctIds AND Id IN (SELECT AccountId FROM Contact)]
);
for (Account a : oldAccounts) {
if (withChildren.containsKey(a.Id)) {
a.addError('Delete the related Contacts before deleting this Account.');
}
}
}
public static void afterInsert(List<Account> newAccounts) { }
public static void afterUpdate(List<Account> newAccounts, Map<Id, Account> oldMap) { }
public static void afterDelete(List<Account> oldAccounts) { }
public static void afterUndelete(List<Account> newAccounts) { }
}The empty methods are deliberate. Apex resolves static method references when the trigger is saved, so every branch the router dispatches to has to exist before the trigger will compile, even if it does nothing yet.
The handler is an ordinary class, which is the practical payoff. You can call its methods directly from a test without constructing database events, and you can unit test the branching in isolation. See our guide to unit testing types and techniques for how that separation changes what a test can assert.
Note: Apex tests confirm your trigger logic is right, but they never open a browser. TestMu AI runs Salesforce UI regression suites across 3,000+ browser and OS combinations so a green deployment does not ship a broken Lightning page. Try TestMu AI free!
The Apex testing documentation requires that unit tests cover at least 75% of your Apex code and that those tests pass. A second requirement is quieter and catches teams out: every trigger must have some test coverage, so one untested trigger blocks a deployment even when the org-wide number is comfortable.
Salesforce is also unusually direct about not optimizing for the number. The same Apex testing documentation tells you not to focus on the percentage covered and instead to cover every use case, including positive and negative cases and both bulk and single records. Our code coverage tutorial explains what actually counts as a covered line.
A trigger test earns its keep by running against a volume a single save never produces. Inserting 200 records exercises the collection handling that a one-record test cannot reach:
@isTest
private class ContactTriggerTest {
@testSetup
static void makeData() {
insert new Account(Name = 'Bulk Test Account');
}
@isTest
static void rollupHandlesBulkInsert() {
Account a = [SELECT Id FROM Account LIMIT 1];
List<Contact> batch = new List<Contact>();
for (Integer i = 0; i < 200; i++) {
batch.add(new Contact(LastName = 'Contact ' + i, AccountId = a.Id));
}
Test.startTest();
insert batch;
Test.stopTest();
Account result = [SELECT Active_Contacts__c FROM Account WHERE Id = :a.Id];
System.assertEquals(200, result.Active_Contacts__c.intValue(),
'Every inserted Contact should increment the parent rollup');
}
}Three details carry the weight here:
Negative paths need their own test. The before delete handler shown earlier blocks a delete with addError, and that block is only proven when a test asserts the delete actually failed. Add this method to the same ContactTriggerTest class so it inherits the testSetup data, and note the allOrNone argument set to false, which returns the failure instead of throwing it:
@isTest
static void blocksDeleteWhenChildrenExist() {
Account a = [SELECT Id FROM Account LIMIT 1];
insert new Contact(LastName = 'Keeper', AccountId = a.Id);
Test.startTest();
Database.DeleteResult dr = Database.delete(a, false);
Test.stopTest();
System.assertEquals(false, dr.isSuccess(),
'Deleting an Account with Contacts should be blocked by the trigger');
}For a broader catalog of what to assert across a Salesforce org, see our Salesforce test case templates and examples.
To debug a trigger, set a debug log on your user in Setup, then reproduce the save. Filter to the APEX_CODE category at FINEST to see trigger entry and exit. Debug logs truncate once they exceed their size cap, and the truncation removes lines from the middle, which is why a System.debug you are certain you added can be missing from the log.
Deactivating one is less obvious, because there is no checkbox in production. Set the status element in the trigger's metadata file and deploy it:
<?xml version="1.0" encoding="UTF-8"?>
<ApexTrigger xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>67.0</apiVersion>
<status>Inactive</status>
</ApexTrigger>Because that round trip needs a deployment, teams that expect to disable logic quickly put a custom setting check at the top of the handler instead, which an administrator can toggle without a release.
Deployment ships the .trigger and .trigger-meta.xml files together. Salesforce runs your org's unit tests during a production deployment, so a trigger whose tests fail elsewhere in the org can block an unrelated release. Wiring that into a pipeline is covered in our guide to why Salesforce test automation breaks and how to fix it.
These three get conflated constantly, and the distinction decides where your logic belongs before you write a line of it.
| Attribute | Apex trigger | Apex class | Record-triggered flow |
|---|---|---|---|
| Invoked by | The platform, on a database event. | Your code, explicitly. | The platform, on a record change. |
| Built by | Developer, in Apex. | Developer, in Apex. | Administrator, declaratively. |
| Static variables | Allowed, but the value is not retained between trigger contexts. | Retained for the whole transaction. | Not applicable. |
| Version control | Plain text, diffs cleanly. | Plain text, diffs cleanly. | XML metadata, hard to review in a diff. |
| Testing | Apex test class required for deployment. | Apex test class required for deployment. | No coverage requirement. |
A practical rule: if an administrator can express it in a flow and the volume is modest, let them. Choose a trigger when you need the previous field values, complex branching, precise bulk control, or a change that must be reviewable in a pull request.
An Apex test class runs entirely server-side. It never renders a page, so it cannot tell you that the Lightning record page reading your trigger's field now shows a stale value, or that a validation error your trigger raises appears in a place no user will look.
That gap is awkward to close with conventional browser automation, because Salesforce Lightning is built on Lightning Web Components and shadow DOM, and selector-based tools struggle to reach inside those components. Kane CLI takes a different route: it drives a real Chrome browser and waits on the rendered viewport rather than on DOM readiness or selector availability, so it acts when the component has actually painted. Objectives are written in natural language instead of selectors, which is what makes Lightning pages tractable without maintaining XPath against generated markup.
A workable division of labor is to let Apex tests own the logic and let browser tests own the interface:
If your team is committed to a selector-based stack, our walkthrough of Salesforce Selenium testing covers the locator strategies and waits that Lightning components demand.
Start by opening your busiest trigger and adding the three Limits lines from the governor limits section, then run it against 1 record and against 200. If the SOQL count climbs with the record count, refactor that trigger to the Set and Map pattern before touching anything else, because that single change removes the most common cause of production failures.
Then write the 200-record test. It is the only artifact that proves the refactor worked and stops the regression from returning, and it satisfies the coverage requirement as a side effect rather than as the goal.
Once the logic is covered, close the interface gap. TestMu AI runs browser-level regression suites against Salesforce Lightning alongside your Apex tests: Kane CLI handles the local and CI runs, and the same natural-language objectives scale on the KaneAI platform, whose getting started documentation walks through authoring them. If you are preparing for a Salesforce role rather than a release, our Salesforce interview questions cover Apex and triggers in depth.
Author
Harshit Paul is Director of Product Marketing at TestMu AI (formerly LambdaTest), with over 8 years of experience in product and growth marketing for developer and QA tools, leading the Agentic AI in Quality Engineering space. He has authored 80+ technical articles for TestMu AI on software testing and automation, and hosted webinars on Selenium, automation testing, browser compatibility, DevOps, and continuous testing. He has led go-to-market and technical marketing initiatives across software testing products, contributing to SEO, content strategy, and developer marketing. He began his career as a certified Salesforce developer at Wipro Technologies, where he worked for 2 years before moving into marketing. Harshit holds a degree in computer programming from Vivekananda Institute of Professional Studies.
Reviewer
Himanshu Sheth is the Director of Marketing (Technical Content) at TestMu AI, with over 8 years of hands-on experience in Selenium, Cypress, and other test automation frameworks. He has authored more than 130 technical blogs for TestMu AI, covering software testing, automation strategy, and CI/CD. At TestMu AI, he leads the technical content efforts across blogs, YouTube, and social media, while closely collaborating with contributors to enhance content quality and product feedback loops. He has done his graduation with a B.E. in Computer Engineering from Mumbai University. Before TestMu AI, Himanshu led engineering teams in embedded software domains at companies like Samsung Research, Motorola, and NXP Semiconductors. He is a core member of DZone and has been a speaker at several unconferences focused on technical writing and software quality.
Did you find this page helpful?
More Related Blogs
TestMu AI forEnterprise
Get access to solutions built on Enterprise
grade security, privacy, & compliance