INSERT-Only vs. UPDATE Logic in Apex Triggers
Overview
A common trigger requirement is to auto-populate a field when a record is first created, but then let users modify that value on subsequent edits. If the trigger logic does not distinguish between insert and update operations, it will silently overwrite user changes every time the record is saved -- a frustrating bug that produces no error message.
This article covers how to use trigger context variables (Trigger.isInsert, Trigger.isUpdate, Trigger.oldMap) to separate insert-only defaults from update-time recalculations. It includes reusable design patterns for three common scenarios: default on insert, recalculate on change, and always-calculated fields. This is a foundational Platform Developer I exam topic.
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.