Half the NullPointerExceptions in trigger code come from reading a context variable that doesn’t exist in the current event. The fix is to memorise the availability matrix and check the event before dereferencing.
The matrix
| Event | Trigger.new | Trigger.newMap | Trigger.old | Trigger.oldMap |
|---|---|---|---|---|
before insert | Yes (no Ids) | No (Ids not assigned yet) | No | No |
after insert | Yes (with Ids) | Yes | No | No |
before update | Yes | Yes | Yes | Yes |
after update | Yes | Yes | Yes | Yes |
before delete | No | No | Yes | Yes |
after delete | No | No | Yes | Yes |
after undelete | Yes | Yes | No | No |
A few clauses to internalise:
before inserthas nonewMap— the platform hasn’t assigned record IDs yet, so a map keyed by Id has no key. Usefor (X x : Trigger.new)instead.- Deletes only expose
old— there are no “new” records during a delete. - Undelete only exposes
new— the records are coming back, so there’s no “old” version. Trigger.newis editable only inbeforetriggers — modifyingTrigger.newinafter insertorafter updatethrows a runtime error.
How Trigger.old differs from Trigger.new
Both are typed lists, but they represent different snapshots:
Trigger.new— the version of the record as it will be saved (inbefore) or as it just was saved (inafter).Trigger.old— the version of the record before the user’s change.
The classic field-change check uses both:
for (Account a : Trigger.new) {
Account before = Trigger.oldMap.get(a.Id);
if (before.Industry != a.Industry) {
// industry changed in this update
}
}
This pattern only works in update events — oldMap isn’t populated in insert/delete/undelete.
Why Trigger.newMap is null in before insert
The map key is the record’s Salesforce Id. During before insert, the records haven’t been written to the database — they don’t have Ids yet. The platform skips populating newMap rather than handing you a map with null keys. After insert runs after the save, so by after insert, every record has an Id and newMap is fully populated.
Why Trigger.new is read-only in after
The save is already done. Mutating Trigger.new in an after trigger would mean updating already-saved fields, which the platform would have to write a second time — an implicit recursive DML. So it just disallows it:
trigger AccountAfter on Account (after update) {
for (Account a : Trigger.new) {
a.Industry = 'Tech'; // throws: System.FinalException: Record is read-only
}
}
If you need to change a field after a save, requery, copy, and update explicitly — but better still, do the work in a before update trigger to avoid the second DML.
A safe-access helper
A common production pattern is a small handler-class helper that hides the matrix behind named methods:
public abstract class TriggerHandler {
protected List<SObject> newRecs() {
return Trigger.new == null ? new List<SObject>() : Trigger.new;
}
protected Map<Id, SObject> oldMap() {
return Trigger.oldMap == null ? new Map<Id, SObject>() : Trigger.oldMap;
}
// ...
}
Now downstream code calls newRecs() and never branches on which event fired.
Common interview follow-ups
- Why is
oldMapnull in inserts? — Because there’s no prior version of a brand-new record. - Can I look up a record’s old value during
after delete? — Yes —Trigger.oldandTrigger.oldMapare both populated; they’re the only way to see what was deleted. - Why does
Trigger.newexist inafter insertif I can’t change it? — So you can run logic that needs the Ids — typically creating related records, queuing async jobs, sending events.
What interviewers are really looking for
Reciting the matrix is the start. The senior signal is explaining why — newMap is empty in before insert because Ids don’t exist yet, Trigger.new is read-only in after because the save is committed. Mention that mutating Trigger.new in after throws FinalException, and that field-change detection requires the update event to compare oldMap against new.
Verified against: Apex Developer Guide — Trigger Context Variables. Last reviewed 2026-05-17.