Apex Triggers · Lesson 25 of 41
Apex Triggers Tutorial: Events and Context Variables
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 needed | work with the saved records' Ids and system fields |
| validate and block the save with addError | create, update or delete other records (children, related objects) |
| run before validation rules and the database save | start 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);
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
| Variable | Contains | Available in |
|---|---|---|
Trigger.new | List of the new versions of the records | insert, update, undelete |
Trigger.newMap | Map<Id, SObject> of new versions | before update, after insert/update/undelete |
Trigger.old | List of the previous versions | update, delete |
Trigger.oldMap | Map<Id, SObject> of previous versions | update, delete |
Trigger.isBefore / isAfter | timing | all |
Trigger.isInsert / isUpdate / isDelete / isUndelete | operation | all |
Trigger.operationType | an enum such as BEFORE_INSERT – ideal for switch | all |
Trigger.size | number 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');
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.addErrordoesn'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.oldMapmakes 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
Write a before insert trigger on Contact that sets
LeadSourceto '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.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.newMapis null in before insert. UseTrigger.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.