Skip to main content

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 VariableAvailable InPurpose
Trigger.isInsertInsert triggersTrue when records are being created
Trigger.isUpdateUpdate triggersTrue when records are being modified
Trigger.isBeforeBefore triggersTrue in before context (can modify Trigger.new)
Trigger.isAfterAfter triggersTrue in after context (records have IDs)
Trigger.oldMapUpdate, DeleteMap of records with their previous values
Trigger.newInsert, UpdateList 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.oldMap without context guard: Accessing Trigger.oldMap.get(item.Id) during an insert throws a NullPointerException.
  • 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.