Salesforce Development
Hands-onIntermediate 45 min· Developer · 10 of 17

Apex triggers and governor limits

Write an Opportunity trigger with a handler class, learn the context variables, and understand governor limits. Learn bulkification so your code survives 200 records at a time.

An Apex trigger is code that runs automatically before or after a record is inserted, updated, deleted or undeleted. Triggers are powerful, and they are also where beginners hit governor limits: hard caps Salesforce puts on each transaction so no one customer can hog the shared servers.

In this tutorial you will write a trigger for Opportunity, use the handler pattern, learn the context variables, bulkify the code, meet the main limits, and avoid the classic mistakes.

Prerequisites: *Apex basics* and *SOQL and SOSL*, and a Developer Edition org.

When to use a trigger

Try the declarative tools first: validation rules, and Flow (see *Automation with Flow*). Flow can handle most field updates and record creation, and admins can maintain it. Use a trigger when you need complex logic, heavy data volumes, or things Flow does not do well. Many orgs use both, so it helps to know what runs when a record is saved: validation rules, before triggers, after triggers, flows and more, in a fixed order described in Salesforce's *Order of Execution* documentation.

Before and after triggers

TypeRunsUse it for
before insert / updateBefore the record is saved to the databaseChanging fields on the same record (no DML needed), validating data with addError
after insert / updateAfter the record is saved, so it has an IdCreating or updating related records, calling other code
before deleteBefore the record is removedBlocking a delete with addError
after delete / after undeleteAfter the record is removed or restoredClean-up work, restoring child data

The events are written in the trigger header, for example before insert, before update, after update. A trigger can listen for several.

The trigger and its context variables

Inside a trigger, the Trigger class tells you what is happening. The important ones:

Trigger.new is the list of new versions of the records. Trigger.old is the list of the old versions (update and delete only). Trigger.newMap and Trigger.oldMap are maps from Id to record (not available in before insert for newMap, because there are no Ids yet). Trigger.isBefore, Trigger.isAfter, Trigger.isInsert, Trigger.isUpdate, Trigger.isDelete tell you which event fired.

Rule 1: always assume a list. A trigger can receive up to 200 records at once (data loads, imports, bulk API). Salesforce hands them over in chunks of 200. Code that works for one record but fails for 200 will break in production.

Build it: stage change on Opportunity

The business rule: (1) a Closed Won opportunity must have an Amount above zero; (2) when an opportunity first becomes Closed Won, create a follow-up Task for its owner. We will put the logic in a handler class and keep the trigger thin.

  1. 1In the Developer Console choose File > New > Apex Class, name it OpportunityTriggerHandler and paste the handler code below. Save with Ctrl+S.
  2. 2Choose File > New > Apex Trigger, name it OpportunityTrigger, choose Opportunity as the sObject and click Submit.
  3. 3Replace the generated text with the trigger code below and save.
  4. 4In your org open an Opportunity, change its Stage to Closed Won with an Amount above zero, and save. Then check the Activities of that record for a new Task.
  5. 5Try again with the Amount empty to see the error message.
OpportunityTriggerHandler.cls
public with sharing class OpportunityTriggerHandler {

    // before insert / before update: block bad data
    public static void validateAmounts(List<Opportunity> opps) {
        for (Opportunity opp : opps) {
            if (opp.StageName == 'Closed Won' && (opp.Amount == null || opp.Amount <= 0)) {
                opp.addError('Amount must be greater than zero for a Closed Won opportunity.');
            }
        }
    }

    // after update: create one task for each opportunity that just became Closed Won
    public static void createFollowUpTasks(List<Opportunity> newOpps, Map<Id, Opportunity> oldMap) {
        List<Task> tasks = new List<Task>();
        for (Opportunity opp : newOpps) {
            Opportunity oldOpp = oldMap.get(opp.Id);
            if (opp.StageName == 'Closed Won' && oldOpp.StageName != 'Closed Won') {
                tasks.add(new Task(
                    Subject = 'Send onboarding email',
                    WhatId = opp.Id,
                    OwnerId = opp.OwnerId,
                    ActivityDate = Date.today().addDays(2),
                    Priority = 'Normal',
                    Status = 'Not Started'
                ));
            }
        }
        if (!tasks.isEmpty()) {
            insert tasks;
        }
    }
}

Two bulk-safe methods. Each loops over all records, collects the work, and does a single insert at the end. The after-update method compares the old and new stage so it fires only on the change.

OpportunityTrigger.trigger
trigger OpportunityTrigger on Opportunity (before insert, before update, after update) {
    if (Trigger.isBefore) {
        OpportunityTriggerHandler.validateAmounts(Trigger.new);
    } else if (Trigger.isAfter && Trigger.isUpdate) {
        OpportunityTriggerHandler.createFollowUpTasks(Trigger.new, Trigger.oldMap);
    }
}

The trigger only decides which handler method to call. Keeping logic in a class makes it easier to test and reuse. A good habit is one trigger per object.

What you should see
Saving a Closed Won opportunity with a blank Amount shows a red error on the record page: 'Amount must be greater than zero for a Closed Won opportunity.' Saving it with an Amount works, and a Task named 'Send onboarding email' appears under Activities.

Why compare oldMap? An after update trigger fires on every edit. Without the old-versus-new check you would create a new Task each time someone fixes a typo in the description.

Governor limits

Salesforce runs many customers on shared servers (multitenancy). Limits keep your code from using more than its share. Exceeding one throws an uncatchable System.LimitException and the whole transaction is rolled back. These are the numbers to know for synchronous Apex (always check the current *Apex Developer Guide* since limits can change):

Limits per transaction

LimitSynchronousAsynchronous
SOQL queries issued100200
Records returned by SOQL50,00050,000
DML statements150150
Records processed by DML10,00010,000
CPU time10,000 ms60,000 ms
Heap size6 MB12 MB
Callouts per transaction100100

A transaction is everything triggered by one save: your trigger, other triggers fired by its DML, flows, and so on. All of it shares the same limit counters. That is why a trigger that is fine on its own can fail when an import or another automation also runs.

Bulkification: the SOQL-in-a-loop mistake

The number one beginner error is a query or DML inside a for loop. With one record it works. With 200 records it runs 200 queries and breaks the 100-query limit. The fix: query once for all records, store results in a Map, and look up inside the loop.

Bulkify a trigger (before insert / before update)
// BAD: one query per opportunity
for (Opportunity opp : Trigger.new) {
    Account acc = [SELECT Id, Industry FROM Account WHERE Id = :opp.AccountId];
    if (acc.Industry == 'Technology') {
        opp.Description = 'Tech deal';
    }
}

// GOOD: one query for all opportunities
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
    if (opp.AccountId != null) {
        accountIds.add(opp.AccountId);
    }
}
Map<Id, Account> accountsById = new Map<Id, Account>(
    [SELECT Id, Industry FROM Account WHERE Id IN :accountIds]
);
for (Opportunity opp : Trigger.new) {
    Account acc = accountsById.get(opp.AccountId);
    if (acc != null && acc.Industry == 'Technology') {
        opp.Description = 'Tech deal';
    }
}

The bad version issues up to 200 queries for a chunk of 200 records (and also throws an error when AccountId is null). The good version issues one. Because this is a before trigger, setting Description needs no DML.

The same pattern applies to DML: build a List, add to it inside the loop and run insert, update or delete once after the loop. Check usage while developing with Limits.getQueries() and Limits.getLimitQueries(), for example System.debug(Limits.getQueries() + ' of ' + Limits.getLimitQueries());.

Common traps

Do not hard-code Ids (record Ids differ between orgs and sandboxes), and never catch a LimitException hoping to carry on: it cannot be caught. Avoid putting a trigger on an object and also a flow that updates the same fields without thinking about the order. Loops of updates can fire each other.

Avoid infinite loops: recursion

If your trigger updates a record that fires the same trigger again, you can create a loop that ends in an error ('maximum trigger depth exceeded'). Prefer designs that do not update the same object. If you must, guard with a static Boolean, remembering that a static variable lasts for one transaction.

TriggerGuard.cls
public with sharing class TriggerGuard {
    private static Boolean alreadyRan = false;

    public static Boolean shouldRun() {
        if (alreadyRan) {
            return false;
        }
        alreadyRan = true;
        return true;
    }
}

// In a trigger: if (TriggerGuard.shouldRun()) { ... }

A simple one-time guard. It is crude: a bulk chunk after the first would be skipped. For production, track the Ids already processed in a static Set instead.

Triggers and tests

You cannot deploy Apex to production without tests that cover at least 75% of your code, and every trigger must have some coverage. You will write them in *Testing Apex*, including a test that creates 200 opportunities to prove your trigger is bulk-safe.

How this comes up in interviews. Be ready for: before versus after trigger and when to use each; what Trigger.new, Trigger.oldMap and Trigger.newMap are; why one trigger per object; what bulkification means; the limits (100 SOQL, 150 DML, 10,000 ms CPU synchronously); how to stop recursion; and when to choose Flow over Apex.

Prompt · Review a trigger for limits
You are a Salesforce technical architect. Review this Apex trigger and handler for governor limit problems and bulkification. List each issue with the line, explain what would happen with 200 records, and give a corrected version.

[paste trigger and handler code]

What to expect: A good answer finds any SOQL or DML inside loops, missing null checks and recursion risks, and rewrites with maps and a single DML. Run the corrected code in a sandbox or Developer org and test with a data load of 200 records.

Try it: Add a second rule

Extend OpportunityTriggerHandler: when a Closed Lost opportunity is saved with a blank Description, block the save with a message asking for a loss reason. Then add a before-delete check that prevents deleting any opportunity whose stage is Closed Won. You will need to add before delete to the trigger header.

Key takeaways

  1. Triggers run before or after insert, update, delete and undelete. Use before to change the same record or validate; use after to work on related records.
  2. Trigger.new, oldMap and newMap hold the records; always write code for a list of up to 200.
  3. Keep triggers thin: one trigger per object that calls a handler class.
  4. Governor limits (100 SOQL, 150 DML, 10,000 ms CPU synchronously) apply to the whole transaction; a LimitException cannot be caught.
  5. Never run queries or DML inside loops: collect Ids, query once into a Map, collect records in a list, save once.

Check yourself

Finishing the quiz marks the lesson as done.

1. You want to set a field on the record being saved, without an extra DML statement. Which trigger event should you use?

2. Why does a query inside a for loop in a trigger fail on a data load?

3. Which statement about governor limits is correct?

4. What is Trigger.oldMap used for in the Opportunity example?

5. What is a good reason to prefer Flow over a trigger?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free