World’s largest virtual agentic engineering & quality conference

WHENAUG 19-21
WHEREVirtual · Global
Register Now
TutorialAutomation

Salesforce Apex Trigger: Syntax, Events, and Test Classes

Salesforce Apex trigger reference: syntax, the 7 trigger events, context variables, order of execution, governor limits, bulkification, and Apex test classes.

Author

Harshit Paul

Author

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?

  • Seven trigger events: before insert, before update, before delete, after insert, after update, after delete, and after undelete. The System.TriggerOperation enum exposes exactly these seven values, which makes it the reliable reference when you route logic by event.
  • Context variable availability: Trigger.new is writable only in before insert and before update, Trigger.old exists only in update and delete, and Trigger.newMap is unavailable in before insert. Reading the wrong one for the event returns null rather than an error.
  • Save order position: in the Salesforce order of execution, before triggers run at step 4 and custom validation rules at step 5, so validation judges values the trigger wrote. After triggers run at step 8, and roll-up summary recalculation happens at steps 16 and 17, well after Apex has finished.
  • Bulkification: in Apex, a SOQL query or DML statement inside a for loop consumes one of the 100 query or 150 statement allowances per record. Collecting IDs into a Set and querying once keeps a Salesforce trigger flat against record volume.
  • Trigger test coverage: Salesforce requires 75% coverage with passing tests to deploy, and separately requires that every trigger carry some coverage, so one untested trigger blocks a deployment on its own.

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.

What Is an Apex Trigger?

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:

  • The transaction commits itself. You never write a commit statement, and an unhandled exception rolls back everything the trigger touched.
  • A static variable declared in the trigger body does not keep its value between trigger contexts, which is why recursion guards live in a separate class.
  • Callouts must be asynchronous, because a synchronous HTTP request would hold the database transaction open while it waits.

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.

How Do You Write an Apex Trigger?

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.

What Are the 7 Apex Trigger Events?

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.

EventUse it whenWatch out for
before insertDefaulting or normalizing field values on the incoming record.Record IDs do not exist yet, so Trigger.newMap is unavailable.
before updateComparing old and new values, then adjusting the record in memory.Issuing DML on the same object here causes recursion.
before deleteBlocking a delete with addError based on related data.Trigger.new does not exist; you only have Trigger.old.
after insertCreating related records that need the new record ID.Trigger.new is read-only; writing to it throws at runtime.
after updateRolling values up to a parent or syncing another object.Fires again after a workflow field update.
after deleteCleaning up or recalculating parents after removal.Only Trigger.old is populated.
after undeleteRestoring 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); }
    }
}

What Are Apex Trigger Context Variables?

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.

VariableWhat it holdsAvailability
Trigger.newList of the new versions of the records.Insert, update, undelete. Writable only in before insert and before update.
Trigger.oldList of the previous versions of the records.Update and delete only. Always read-only.
Trigger.newMapMap of record ID to new record version.Before update, after insert, after update, after undelete. Not in before insert.
Trigger.oldMapMap of record ID to previous record version.Update and delete only.
Trigger.operationTypeEnum identifying the current event.All events. The cleanest value to branch on.
Trigger.sizeNumber of records in the current invocation.All events. Counts this invocation, not the whole operation.
Trigger.isExecutingTrue 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.

When Does a Trigger Fire in the Order of Execution?

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.

  • Step 4 executes all before triggers.
  • Step 5 runs most system validation again and runs any custom validation rules.
  • Step 8 executes all after triggers.
  • Steps 16 and 17 recalculate roll-up summary fields on the parent and then the grandparent record.

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.

What Governor Limits Apply Inside a Trigger?

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:

LimitValueHow a trigger burns it
SOQL queries, synchronous100One query inside a for loop over more than 100 records.
SOQL queries, asynchronous200Doubles only once execution moves to a future or Queueable context.
Records retrieved by SOQL50,000An unfiltered query against a large object.
DML statements150Calling update inside a loop instead of once on a collection.
Records processed by DML10,000Cascading writes that fan out across related records.
CPU time, synchronous10,000 msNested loops over large collections.
Heap size, synchronous6 MBHolding large query results in memory at once.
Stack depth, recursive triggers16A 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.

Detect and fix flaky tests with TestMu AI

How Do You Bulkify an Apex Trigger?

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.

How Do You Stop a Trigger From Running Twice?

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.

Why Use a Trigger Handler Class?

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

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!

How Do You Write a Test Class for a Trigger?

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:

  • The testSetup method creates records once and rolls them back between test methods, which keeps each test isolated without repeating setup code.
  • Test.startTest and Test.stopTest give the code between them a fresh set of governor limits, so setup data does not consume the allowance the assertion depends on.
  • Apex tests cannot reach your org's existing records by default, so the rows your assertions see are the ones the test creates. Setup and metadata objects such as User, Profile, and RecordType are the documented exception. Our guide to test data in software testing covers designing that data deliberately.

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.

How Do You Debug, Deactivate, and Deploy a Trigger?

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.

Apex Trigger vs Apex Class vs Record-Triggered Flow

These three get conflated constantly, and the distinction decides where your logic belongs before you write a line of it.

AttributeApex triggerApex classRecord-triggered flow
Invoked byThe platform, on a database event.Your code, explicitly.The platform, on a record change.
Built byDeveloper, in Apex.Developer, in Apex.Administrator, declaratively.
Static variablesAllowed, but the value is not retained between trigger contexts.Retained for the whole transaction.Not applicable.
Version controlPlain text, diffs cleanly.Plain text, diffs cleanly.XML metadata, hard to review in a diff.
TestingApex 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.

Automate web and mobile tests with KaneAI by TestMu AI

What Apex Tests Cannot Catch

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:

  • Apex tests assert field values, bulk behavior at volume, and the addError paths that block a save.
  • Browser tests assert that the record page renders the derived value, that the error surfaces where a user sees it, and that the page still works after a seasonal release.
  • Both run in the pipeline, because a deployment that passes Apex tests can still ship a broken page.

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.

Conclusion

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

Blogs: 81

  • Twitter
  • Linkedin

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

Reviewer

  • Linkedin

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.

Open in ChatGPT Icon

Open in ChatGPT

Open in Claude Icon

Open in Claude

Open in Perplexity Icon

Open in Perplexity

Open in Grok Icon

Open in Grok

Open in Gemini AI Icon

Open in Gemini AI

Copied to Clipboard!
...

3000+ Browsers. One Platform.

See exactly how your site performs everywhere.

Try it free
...

Write Tests in Plain English with KaneAI

Create, debug, and evolve tests using natural language.

Try for free
...
TestMu Conf 2026

World's largest virtual agentic engineering & quality conference

...

AUG 19-21, 2026

REGISTER NOW

Salesforce Apex Trigger FAQs

Did you find this page helpful?

More Related Blogs

TestMu AI forEnterprise

Get access to solutions built on Enterprise
grade security, privacy, & compliance

  • Advanced access controls
  • Advanced data retention rules
  • Advanced Local Testing
  • Premium Support options
  • Early access to beta features
  • Private Slack Channel
  • Unlimited Manual Accessibility DevTools Tests