LearnFrenzy
Sign in Take a free test

Apex Triggers · Lesson 25 of 41

Apex Triggers Tutorial: Events and Context Variables

6 min read Updated Nov 8, 2026 Saurabh Samir

A trigger is Apex that runs automatically when records are inserted, updated, deleted or undeleted – whether the change comes from a user, an import, an API call or a Flow. Triggers are where much of an org's business logic lives, which is why they're the most important – and most interviewed-on – part of Apex.

In this lesson

  • Trigger syntax and the seven events
  • Before vs after triggers
  • Context variables: Trigger.new, old, newMap, oldMap…
  • Detecting field changes
  • Blocking a save with addError

Syntax and events

trigger TriggerName on ObjectName (before insert, before update, before delete,
                                   after insert, after update, after delete, after undelete) {
    // code
}
Use a before trigger to…Use an after trigger to…
set or correct fields on the records being saved – no DML neededwork with the saved records' Ids and system fields
validate and block the save with addErrorcreate, update or delete other records (children, related objects)
run before validation rules and the database savestart async work, publish events, call out (via async)

A first trigger

// AccountTrigger.trigger
trigger AccountTrigger on Account (before insert, before update, after insert) {
    if (Trigger.isBefore) {
        for (Account acc : Trigger.new) {
            acc.Name = acc.Name.trim();                         // change the record itself – no DML
            if (acc.Rating == null) acc.Rating = 'Warm';        // default a field
        }
        System.debug('before ' + (Trigger.isInsert ? 'insert' : 'update') + ': ' + Trigger.size + ' record(s)');
    }

    if (Trigger.isAfter && Trigger.isInsert) {
        List<Task> followUps = new List<Task>();
        for (Account acc : Trigger.new) {                       // Ids exist now
            followUps.add(new Task(Subject = 'Welcome call: ' + acc.Name, WhatId = acc.Id, Priority = 'Normal'));
        }
        insert followUps;                                       // DML on OTHER records: after trigger
        System.debug('after insert: created ' + followUps.size() + ' task(s)');
    }
}
List<Account> accs = new List<Account>{
    new Account(Name = '  Acme Corp  '),
    new Account(Name = 'Globex', Rating = 'Hot')
};
insert accs;

for (Account a : [SELECT Name, Rating, (SELECT Subject FROM Tasks) FROM Account ORDER BY Name]) {
    System.debug('"' + a.Name + '" rating ' + a.Rating + ', tasks: ' + a.Tasks[0].Subject);
}

accs[0].Rating = null;
update accs[0];
System.debug('Rating after update: ' + [SELECT Rating FROM Account WHERE Id = :accs[0].Id].Rating);

Debug log

before insert: 2 record(s)
after insert: created 2 task(s)
"Acme Corp" rating Warm, tasks: Welcome call: Acme Corp
"Globex" rating Hot, tasks: Welcome call: Globex
before update: 1 record(s)
Rating after update: Warm

The before trigger trimmed the name and defaulted the rating without any DML – changes to Trigger.new in a before trigger are saved automatically. The after trigger, running once the records had Ids, created related Tasks. One trigger fired once for both accounts: triggers always receive records in bulk.

Context variables

VariableContainsAvailable in
Trigger.newList of the new versions of the recordsinsert, update, undelete
Trigger.newMapMap<Id, SObject> of new versionsbefore update, after insert/update/undelete
Trigger.oldList of the previous versionsupdate, delete
Trigger.oldMapMap<Id, SObject> of previous versionsupdate, delete
Trigger.isBefore / isAftertimingall
Trigger.isInsert / isUpdate / isDelete / isUndeleteoperationall
Trigger.operationTypean enum such as BEFORE_INSERT – ideal for switchall
Trigger.sizenumber of records in this execution (max 200 per chunk)all

Trigger.newMap isn't available in before insert – the records have no Ids yet. And in after triggers, Trigger.new is read-only: to change those records you'd need DML, which is why field changes belong in before triggers.

Detecting changes and blocking saves

Compare new and old values to react only when a field actually changes, and use addError to stop a save with a message the user sees:

// OpportunityTrigger.trigger
trigger OpportunityTrigger on Opportunity (before update) {
    for (Opportunity opp : Trigger.new) {
        Opportunity old = Trigger.oldMap.get(opp.Id);

        if (old.StageName == 'Closed Won' && opp.StageName != 'Closed Won') {
            opp.StageName.addError('A won deal cannot be reopened. Create a new opportunity instead.');
            continue;
        }
        if (opp.Amount != old.Amount && opp.Amount < old.Amount * 0.5) {
            opp.addError('Amount cannot drop by more than half in one edit.');
        }
    }
}
Opportunity won = new Opportunity(Name = 'Acme renewal', StageName = 'Closed Won', CloseDate = Date.today(), Amount = 100000);
Opportunity open = new Opportunity(Name = 'Globex pilot', StageName = 'Prospecting', CloseDate = Date.today(), Amount = 80000);
insert new List<Opportunity>{ won, open };

won.StageName = 'Negotiation/Review';
open.Amount = 20000;
Opportunity fine = new Opportunity(Id = open.Id, Description = 'notes only');

List<Database.SaveResult> results = Database.update(new List<Opportunity>{ won, open }, false);
for (Database.SaveResult r : results) {
    System.debug(r.isSuccess() ? 'saved' : 'blocked: ' + r.getErrors()[0].getMessage() + ' ' + r.getErrors()[0].getFields());
}
update fine;
System.debug('A change that breaks no rule saves normally');

Debug log

blocked: A won deal cannot be reopened. Create a new opportunity instead. (StageName)
blocked: Amount cannot drop by more than half in one edit. ()
A change that breaks no rule saves normally
  • field.addError() shows the message next to the field in the UI; record.addError() at the top of the page.
  • addError doesn't throw: the trigger keeps running, and the record simply fails to save. With partial-success DML the other records still save.
  • Comparing with Trigger.oldMap makes the rule fire only on real changes, not on every edit.

Trigger rules worth memorising

  • Triggers run in system mode – the running user's permissions aren't checked unless you code it.
  • Records arrive in chunks of up to 200: always loop and always bulkify.
  • Callouts can't run synchronously in a trigger – enqueue a Queueable or use @future(callout=true).
  • Keep logic out of the trigger body: delegate to a handler class (next lesson).

Practice

Try it yourself

  1. Write a before insert trigger on Contact that sets LeadSource to 'Web' when it's blank.

    Show answer

    trigger ContactTrigger on Contact (before insert) { for (Contact c : Trigger.new) { if (String.isBlank(c.LeadSource)) c.LeadSource = 'Web'; } } – no DML needed.

  2. Why does Trigger.newMap.get(acc.Id) fail in a before insert trigger?

    Show answer

    Records don't have Ids before they're inserted, so Trigger.newMap is null in before insert. Use Trigger.new.

Quick check

Where should you set fields on the records being saved?

In a before trigger, directly on Trigger.new – no DML.

Which context variable holds previous values keyed by Id?

Trigger.oldMap.

How do you stop a record from saving with a message?

record.addError('message') or record.Field.addError('message').

Summary

  • Triggers run on insert, update, delete and undelete, before or after the save.
  • Before: change and validate the records being saved. After: work with Ids and other records.
  • Use Trigger.new/old/newMap/oldMap to read records and detect changes.
  • Block saves with addError; always handle records in bulk.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links