INSERT-Only vs. UPDATE Logic in Apex Triggers
A common trigger requirement is to auto-populate a field when a record is created but allow users to override that value on subsequent edits. Getting this wrong means either the field never gets a default value, or user changes are silently overwritten every time the record is saved. This distinction between insert and update behavior is a foundational PD1 exam topic.
The Problem: Overwriting User Edits
Consider a trigger that sets a default supplier on a purchase order line item based on its product:
// BAD: Overwrites the supplier every time the record is saved
trigger POLineItemTrigger on PO_Line_Item__c (before insert, before update) {
Set<Id> productIds = new Set<Id>();
for (PO_Line_Item__c item : Trigger.new) {
productIds.add(item.Product__c);
}
Map<Id, Product2> products = new Map<Id, Product2>([
SELECT Id, Default_Supplier__c FROM Product2 WHERE Id IN :productIds
]);
for (PO_Line_Item__c item : Trigger.new) {
Product2 prod = products.get(item.Product__c);
if (prod != null) {
item.Supplier__c = prod.Default_Supplier__c; // Always overwrites
}
}
}
A procurement specialist manually changes the supplier to a preferred vendor, saves the record, and the trigger immediately reverts it to the default. The user sees their change "not stick" with no error message -- a frustrating and invisible bug.
The Solution: Guard with Trigger Context
Use Trigger.isInsert to restrict auto-population to new records only:
trigger POLineItemTrigger on PO_Line_Item__c (before insert, before update) {
// Only auto-populate on INSERT
if (Trigger.isInsert) {
Set<Id> productIds = new Set<Id>();
for (PO_Line_Item__c item : Trigger.new) {
if (item.Supplier__c == null) { // Only if not already set
productIds.add(item.Product__c);
}
}
if (!productIds.isEmpty()) {
Map<Id, Product2> products = new Map<Id, Product2>([
SELECT Id, Default_Supplier__c FROM Product2
WHERE Id IN :productIds
]);
for (PO_Line_Item__c item : Trigger.new) {
if (item.Supplier__c == null) {
Product2 prod = products.get(item.Product__c);
if (prod?.Default_Supplier__c != null) {
item.Supplier__c = prod.Default_Supplier__c;
}
}
}
}
}
// UPDATE logic that runs on every save
if (Trigger.isUpdate) {
for (PO_Line_Item__c item : Trigger.new) {
PO_Line_Item__c oldItem = Trigger.oldMap.get(item.Id);
if (item.Quantity__c != oldItem.Quantity__c) {
item.Total__c = item.Quantity__c * item.Unit_Price__c;
}
}
}
}
Understanding Trigger Context Variables
| Context Variable | Available In | Purpose |
|---|---|---|
Trigger.isInsert | Insert triggers | True when records are being created |
Trigger.isUpdate | Update triggers | True when records are being modified |
Trigger.isBefore | Before triggers | True in before context (can modify Trigger.new) |
Trigger.isAfter | After triggers | True in after context (records have IDs) |
Trigger.oldMap | Update, Delete | Map of records with their previous values |
Trigger.new | Insert, Update | List of records with current values |
Trigger.oldMap is null during insert operations. Accessing it without a guard causes a NullPointerException. This is an alternative way to detect insert context:
if (Trigger.oldMap == null) {
// This is an INSERT -- no previous values exist
}
Design Patterns for Insert vs. Update
Pattern 1: Default on Insert, Recalculate on Change
Set a default value on insert. On update, only recalculate if a dependency changes:
if (Trigger.isInsert) {
item.Region__c = deriveRegionFromAccount(item.Account__c);
} else if (Trigger.isUpdate) {
PO_Line_Item__c old = Trigger.oldMap.get(item.Id);
if (item.Account__c != old.Account__c) {
item.Region__c = deriveRegionFromAccount(item.Account__c);
}
}
Pattern 2: Set Once, Never Override
For fields that should be set exactly once and never change:
if (Trigger.isInsert) {
item.Original_Price__c = item.Unit_Price__c;
}
// No update logic -- Original_Price__c is immutable after creation
Pattern 3: Always Calculate (Derived Fields)
Some fields should always reflect the current state. These are safe to set on every save:
// Runs on both insert and update -- this is a calculated field, not user-editable
item.Extended_Amount__c = item.Quantity__c * item.Unit_Price__c;
Common Pitfalls
- Forgetting the null check on insert: Even on insert, a user or integration might pre-populate the field. Always check
if (field == null)before defaulting. - Using
Trigger.oldMapwithout context guard: AccessingTrigger.oldMap.get(item.Id)during an insert throws aNullPointerException. - Applying update-only logic to inserts: Change detection (
new.Field != old.Field) does not work on insert because there is no old value.
The key principle: treat field population as a deliberate design decision. Document whether each auto-populated field is insert-only, change-triggered, or always-calculated, and code the trigger logic accordingly.