The Problem in One Sentence

A trigger that works on one record and fails on two hundred is not a working trigger.

Salesforce processes records in batches. Data Loader inserts 200 at a time. APIs bulk-create. Mass operations reassign thousands. Your trigger gets called once per batch, and it has a budget of 100 SOQL queries and 150 DML statements. Fit all the logic for 200 records into that budget.

Bulkification is not optional. It is the minimum bar for production Apex.

Rule 1: Never SOQL Inside a Loop

// BAD
for (Account a : Trigger.new) {
    List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId = :a.Id];
    // ...
}

For 200 accounts, that’s 200 queries — past the governor limit by record 101.

// GOOD
Set<Id> accountIds = new Set<Id>();
for (Account a : Trigger.new) accountIds.add(a.Id);

Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds]) {
    if (!contactsByAccount.containsKey(c.AccountId)) {
        contactsByAccount.put(c.AccountId, new List<Contact>());
    }
    contactsByAccount.get(c.AccountId).add(c);
}

for (Account a : Trigger.new) {
    List<Contact> contacts = contactsByAccount.get(a.Id);
    // ...
}

One query, regardless of batch size.

Rule 2: Never DML Inside a Loop

Same principle, write side.

// BAD
for (Account a : Trigger.new) {
    update someOtherRecord;
}
// GOOD
List<SomeObject__c> toUpdate = new List<SomeObject__c>();
for (Account a : Trigger.new) {
    // ... build record
    toUpdate.add(someOtherRecord);
}
if (!toUpdate.isEmpty()) update toUpdate;

One DML, regardless of batch size.

Rule 3: Use Maps for O(1) Lookups

Linear searches through a list inside a loop make the trigger O(n²). At n=200, that’s 40,000 iterations. At n=10,000 (via Batch Apex), it’s 100 million.

Build maps once. Look up by key. Always.

Rule 4: Separate Trigger Logic into a Handler

An If-forest inside the trigger file does not scale. One trigger per object, dispatching to a handler class, scales to whatever complexity you need.

trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
    new AccountTriggerHandler().run();
}

The handler pattern lets you structure logic by context — handleBeforeInsert(), handleAfterUpdate() — and lets you write unit tests at the handler level, not the trigger level.

Rule 5: One Trigger Per Object

Multiple triggers on the same object execute in undefined order. When order matters, you’re debugging randomness. Consolidate.

If separate teams own different logic, use a framework that registers handlers with explicit order rather than splitting triggers.

Rule 6: Respect Recursion

A trigger that updates records on the object it’s triggering on will re-fire. Without a guard, you loop.

Use a static variable in a utility class:

public class TriggerState {
    public static Boolean alreadyProcessedAccountUpdate = false;
}

// In the handler:
if (TriggerState.alreadyProcessedAccountUpdate) return;
TriggerState.alreadyProcessedAccountUpdate = true;
// ... update logic

Static variables persist for the lifetime of the request, not the session. They reset on every Apex transaction — which is what you want.

Rule 7: Use Trigger.oldMap for Change Detection

Don’t compare Trigger.new[i] against Trigger.old[i] by index. Use the maps.

for (Account a : Trigger.new) {
    Account oldRecord = Trigger.oldMap.get(a.Id);
    if (a.Industry != oldRecord.Industry) {
        // react to industry change
    }
}

This idiom is cleaner, safer, and works even when you’re iterating a subset.

Rule 8: Early Return When Nothing Changed

Triggers fire on every update, even when nothing relevant changed. Early-return on no-ops:

List<Account> relevantAccounts = new List<Account>();
for (Account a : Trigger.new) {
    if (a.Industry != Trigger.oldMap.get(a.Id).Industry) {
        relevantAccounts.add(a);
    }
}
if (relevantAccounts.isEmpty()) return;

This alone can 10x performance on objects with many non-relevant updates per day.

Rule 9: Handle Partial Failures

DML with allOrNone=false lets you process valid records while logging failures.

Database.SaveResult[] results = Database.update(toUpdate, false);
for (Integer i = 0; i < results.size(); i++) {
    if (!results[i].isSuccess()) {
        // log toUpdate[i] and results[i].getErrors()
    }
}

Use this for integration-driven batches where one bad record shouldn’t block a hundred good ones.

Rule 10: Test Bulk, Not Just Single

Every trigger test class should include a test method that inserts or updates at least 200 records. Use Test.startTest() / Test.stopTest() to get fresh governor limits for the bulk section.

@isTest
static void testBulkInsert() {
    List<Account> accounts = new List<Account>();
    for (Integer i = 0; i < 200; i++) {
        accounts.add(new Account(Name = 'Test ' + i, Industry = 'Technology'));
    }
    Test.startTest();
    insert accounts;
    Test.stopTest();
    // assertions
}

A trigger that passes single-record tests but has never seen a bulk test is a ticking bomb.

Verification Checklist

Before deploying a trigger, walk through this list:

  • No SOQL inside a for/while/do loop.
  • No DML inside a loop.
  • Maps used for record lookups by Id.
  • Trigger delegates to a handler class.
  • One trigger per object in this org.
  • Recursion guards in place for self-referential updates.
  • Uses Trigger.oldMap for change detection, not index access.
  • Early-return on records where no relevant field changed.
  • Bulk test method exists and processes ≥ 200 records.
  • Handles partial failures with allOrNone=false where appropriate.

Ten items. Every trigger passes all ten or doesn’t deploy.

Frequently Asked Questions

What about aggregations across thousands of records?

Synchronous triggers cap at 10,000 records processed by DML. For larger volumes, roll up asynchronously via Queueable or Batch Apex, or use Custom Rollup fields if the aggregation is simple.

How do I detect SOQL-in-loop in code review?

Static analysis: the Salesforce CLI’s sf code-analyzer run catches most cases. For what escapes static analysis, code review with a checklist catches the rest.

Can I rely on the Flow Builder instead?

For simple declarative automation, yes — and bulkification of Flow-based logic is essentially automatic when you avoid Get Records and DML inside loops. For complex logic or performance-critical paths, Apex gives finer control.

Are there exceptions to these rules?

Very rare. If you think you have one, write the test at 10x expected scale and prove the trigger survives.

Share