Bulkifying Apex Triggers: Patterns and Pitfalls

A trigger that works for one record can fail for two hundred. Here is how to rewrite per-record logic into bulk-safe Apex and test that it stays that way.

Type
Article
Topic
CRM & Engineering
Reading time
6 min
Updated
Author
GeekMidst Team
Reviewed by
Site owner
Banner for the GeekMidst article “Bulkifying Apex Triggers: Patterns and Pitfalls”, covering Why triggers see many records and A trigger that is…

The trigger passed every test and worked perfectly when users edited records one at a time. Then someone ran a Data Loader update, or an integration sent a batch, and the job failed with System.LimitException: Too many SOQL queries. That error almost always means the trigger was written for one record, not for a list of them.

Bulkifying a trigger means writing it so that the number of queries and DML statements stays the same whether it processes one record or two hundred.

Checklist of the bulk-safe Apex trigger pattern: thin trigger, collect IDs, query once, process in memory, write once and test with over 200 records
Collect, query once, process in memory and write once, so queries and DML stay constant whether a batch holds one record or two hundred. View full size

Why triggers see many records

A trigger does not fire once per record. It fires once per batch of records saved together, and Trigger.new holds all of them. Triggers process records in chunks of up to 200, so a single DML statement on 1,000 records runs the trigger five times within one transaction, and every query and DML statement inside the trigger counts against shared governor limits. In a synchronous transaction you get 100 SOQL queries, and other automation on the same save (flows, other triggers, managed packages) uses the same allowance. Check the current limits table in the Apex Developer Guide for the full list.

The practical rule follows from that: no SOQL and no DML inside a loop over records.

A trigger that is not bulk-safe

This trigger keeps a contact count on Account. It queries and updates once per contact, so 200 contacts mean 200 queries and 200 updates.

apex
// Anti-pattern: one query and one update per record
trigger ContactTrigger on Contact (after insert) {
    for (Contact c : Trigger.new) {
        Account a = [SELECT Id, Contact_Count__c FROM Account WHERE Id = :c.AccountId];
        a.Contact_Count__c = a.Contact_Count__c + 1;
        update a;
    }
}
Do not use. Fails on bulk inserts and ignores updates, deletes and undeletes.

It has three other problems besides limits. It only handles inserts, so moving a contact between accounts or deleting one makes the count wrong. It fails when AccountId is null. And incrementing a stored number drifts over time; recalculating from the source is more reliable.

The bulk-safe pattern

The standard approach has four steps:

  1. Collect the IDs you care about into a Set by looping over the trigger records.
  2. Query once for everything you need, using IN :thatSet, and put the results in a Map.
  3. Process in memory, building a list of records to change.
  4. Write once with a single DML statement on the list.

Keep the trigger itself thin and put logic in a handler class. That makes it testable and lets you control the order of operations when an object has several requirements.

Assumptions

Account has a custom number field Contact_Count__c. The handler is declared without sharing on purpose: a count should include contacts the running user cannot see. Decide sharing deliberately for each handler rather than copying a default.

apex
trigger ContactTrigger on Contact (after insert, after update, after delete, after undelete) {
    ContactCountHandler.recalculate(Trigger.new, Trigger.oldMap);
}
One trigger per object, with no logic in the trigger body.
apex
public without sharing class ContactCountHandler {

    public static void recalculate(List<Contact> newList, Map<Id, Contact> oldMap) {
        Set<Id> accountIds = new Set<Id>();

        if (newList != null) {
            for (Contact c : newList) {
                addIfPresent(accountIds, c.AccountId);
                Contact prior = (oldMap == null) ? null : oldMap.get(c.Id);
                if (prior != null && prior.AccountId != c.AccountId) {
                    addIfPresent(accountIds, prior.AccountId);
                }
            }
        } else {
            for (Contact c : oldMap.values()) {
                addIfPresent(accountIds, c.AccountId);
            }
        }
        if (accountIds.isEmpty()) {
            return;
        }

        Map<Id, Integer> counts = new Map<Id, Integer>();
        for (AggregateResult ar : [
            SELECT AccountId accId, COUNT(Id) total
            FROM Contact
            WHERE AccountId IN :accountIds
            GROUP BY AccountId
        ]) {
            counts.put((Id) ar.get('accId'), (Integer) ar.get('total'));
        }

        List<Account> updates = new List<Account>();
        for (Id accId : accountIds) {
            Integer total = counts.containsKey(accId) ? counts.get(accId) : 0;
            updates.add(new Account(Id = accId, Contact_Count__c = total));
        }
        update updates;
    }

    private static void addIfPresent(Set<Id> ids, Id value) {
        if (value != null) {
            ids.add(value);
        }
    }
}
One aggregate query and one update per batch, regardless of batch size. Handles inserts, re-parenting, deletes and undeletes.

Whatever the batch size, this uses one query and one DML statement. On delete, Trigger.new is null, so the handler reads the old values; deleted contacts are excluded from the count because SOQL does not return records in the Recycle Bin by default. On update, only contacts that changed account add the old account to the set.

For a simple count like this you could also use a standard roll-up summary field if the relationship were master-detail, or a declarative rollup tool. Use code when the rule is more involved.

Other bulkification habits

  • Use Trigger.newMap and Trigger.oldMap to compare old and new values instead of querying for the previous state.
  • Filter early. Only add a record to the work set if a relevant field changed. That keeps queries smaller and avoids unnecessary updates that fire more automation.
  • Query related data with relationship queries rather than several separate queries.
  • Avoid boolean recursion flags. A static Boolean hasRun stops the trigger running for the second chunk of 200 in the same transaction, silently skipping records. If you need a recursion guard, track processed record IDs in a static Set<Id>.
  • Move slow work out of the transaction. Callouts are not allowed directly in triggers; enqueue a Queueable job and pass it the record IDs.

Test with more than 200 records

A bulk test should insert enough records to cross a chunk boundary, then assert the outcome. Wrap the operation in Test.startTest() and Test.stopTest() so it gets a fresh set of limits. Testing with 201 or more records helps catch recursion flags that only break on the second chunk. Also test re-parenting, deletes and undeletes, since those are the paths most often forgotten. Our guide to writing reliable Apex tests covers test data and assertions in more depth.

Diagnosing a limit error

  1. Open the debug log for the failing transaction and look at the cumulative limits section near the end.
  2. Find which code unit used most of the queries or DML. It may not be your trigger; flows and managed packages share the same budget.
  3. Search that code for SOQL or DML inside for loops, including inside methods called from loops.
  4. Check for automation that updates the same records again, which re-fires triggers.

Questions we hear

Should every object have only one trigger?
It is the widely recommended practice, because Salesforce does not guarantee the order in which multiple triggers on the same object and event run. One trigger delegating to a handler keeps the order under your control.
Do I need a trigger framework?
Not necessarily. A thin trigger and a handler class with clear methods per event is enough for many orgs. A framework helps when several teams work on the same objects and need consistent bypass and ordering rules.
Is Flow bulk-safe in the same way?
Record-triggered flows are also subject to governor limits and need similar care, such as avoiding data elements inside loops. See our record-triggered flow best practices.
Can I bulkify code that makes callouts?
Callouts cannot run directly in a trigger. Collect the record IDs and pass them to a Queueable job, which can make the callouts and handle errors outside the user's save.

Hitting governor limits in production?

We can trace which automation is consuming your limits, refactor the triggers involved and add bulk tests so the problem stays fixed.

Request a code review →

Tell us what you are trying to fix.

Share your Salesforce setup, goals and constraints. A practitioner reads every message and replies with questions, first thoughts or an honest view of whether you need outside help at all.

Talk to a Salesforce Expert
Book a Consultation