Bulkification Best Practices in Apex

If you’ve ever deployed an Apex trigger that worked perfectly in your dev sandbox, then blew up in production during a data import — you already know why bulkification matters.

Apex bulkification is the practice of writing code that handles multiple records efficiently, not just one at a time. It’s one of the first things Salesforce expects from any developer working on the platform, and getting it wrong is one of the most common reasons for hitting governor limits.

This guide walks you through everything you need: what bulkification is, why it’s non-negotiable, the anti-patterns that trip people up, and the patterns that actually work — with real code examples throughout.

Table of Contents

  1. What is Bulkification?
  2. Why Bulkification Matters
  3. Common Anti-patterns to Avoid
  4. Bulkification Patterns That Work
  5. Bulkifying Triggers
  6. Bulkifying Classes
  7. Testing Bulk Operations
  8. Code Examples
  9. FAQ

What is Bulkification?

Definition and Importance

Apex bulkification is the practice of designing your Apex code to process collections of records in a single operation, rather than handling one record at a time.

When Salesforce executes your code — whether triggered by a user clicking Save, a data loader import, or an API call — it can send up to 200 records to your trigger at once. If your code isn’t built to handle that gracefully, things break fast.

Think of it like a grocery store checkout. You wouldn’t scan one item, bag it, make a payment, then start over for the next item. You scan everything, bag it all, and pay once. Bulkification is that same principle applied to database operations.

Most governor limit violations trace back directly to code that wasn’t written with bulk scenarios in mind. One record? Fine. Two hundred records? System error.

Multi-tenant Architecture Impact

Salesforce runs on a multi-tenant architecture, meaning your org shares computing resources with thousands of other customers. To protect everyone, Salesforce enforces strict governor limits on how many operations your code can perform per transaction.

This isn’t a punishment — it’s what keeps Salesforce stable and performant at scale. But it does mean your code has to be smart about how it uses resources. Writing bulk-safe Apex code isn’t optional; it’s the baseline expectation on the platform.


Why Bulkification Matters

Governor Limits

Salesforce enforces per-transaction limits that are easy to hit if your code isn’t bulkified. The key ones to know:

Governor LimitThreshold
SOQL queries per transaction100
DML statements per transaction150
Records processed by DML10,000
CPU time10,000 ms
Heap size6 MB

If your trigger runs one SOQL query per record and receives 200 records, that’s 200 queries — double the 100-query limit. The transaction fails, your users get errors, and your data is left in an inconsistent state.

Bulkification isn’t about elegance; it’s about your code actually working in production.

salesforce

Read this official Salesforce documentation on Salesforce Governor Limits.

Performance Benefits

Beyond just avoiding errors, bulkified code is genuinely faster. A single SOQL query returning 200 records takes a fraction of the time that 200 individual queries would take, even if governor limits weren’t a factor.

When you process multiple records in bulk, you also reduce round trips to the database, lower CPU usage, and make your transactions far less likely to time out. The performance difference becomes especially noticeable during data migrations and batch processes where you’re working with tens of thousands of records.


Common Anti-patterns to Avoid

These are the patterns you’ll see in code reviews that make experienced Salesforce developers cringe. Knowing what not to do is half the battle.

SOQL in Loops

This is the single most common mistake in Apex code. Never put a SOQL query inside a for loop.

Apex Code

// ❌ WRONG - This will hit governor limits fast
for (Account acc : trigger.new) {
    List<Contact> contacts = [SELECT Id, Name FROM Contact WHERE AccountId = :acc.Id];
    // do something with contacts
}

With 200 trigger records, this executes 200 separate SOQL queries. You only get 100 per transaction. Your code fails at record 101.

sfdcrocks

Read this article on Avoiding SOQL 101 Error.

DML in Loops

Same problem, different statement. Putting DML operations (insert, update, delete, upsert) inside a loop burns through your 150 DML limit quickly.

Apex Code

// ❌ WRONG - DML inside a loop
for (Case c : trigger.new) {
    Task t = new Task(WhatId = c.Id, Subject = 'Follow up');
    insert t; // Each insert = one DML statement
}

Single-record Thinking

Sometimes the issue isn’t loops explicitly — it’s designing logic that only works for one record. If your trigger reads trigger.new[0] or trigger.new.size() == 1 without accounting for bulk scenarios, it’ll silently fail or produce wrong results when more records are processed.

Apex Code

// ❌ WRONG - Assumes only one record
Account acc = trigger.new[0]; // What about records 1-199?

Bulkification Patterns That Work

Here are the patterns that replace those anti-patterns. These are the building blocks of bulk-safe Apex code.

Collection-based Processing

Instead of processing records one at a time, collect what you need first, then process everything together.

Apex Code

// ✅ CORRECT - Collect IDs first, query once
Set<Id> accountIds = new Set<Id>();
for (Account acc : trigger.new) {
    accountIds.add(acc.Id);
}
List<Contact> contacts = [SELECT Id, AccountId FROM Contact WHERE AccountId IN :accountIds];

One query, no matter how many records come in. This is the core principle behind bulk processing in Salesforce.

sfdcrocks

Read this article to get a good understanding on Apex Data Types and Collections.

Map-based Lookups

Maps let you look up parent or related records instantly by ID after a single bulk query. This pattern comes up constantly in trigger logic.

Apex Code

// ✅ CORRECT - Map for efficient lookups
Map<Id, Account> accountMap = new Map<Id, Account>(
    [SELECT Id, Industry FROM Account WHERE Id IN :accountIds]
);

for (Contact con : trigger.new) {
    Account parentAccount = accountMap.get(con.AccountId);
    if (parentAccount != null && parentAccount.Industry == 'Technology') {
        // apply logic
    }
}

The query happens once. The loop just reads from an in-memory map — blazing fast, zero additional queries.

Set for Unique Values

When you need to collect IDs or unique values without duplicates, use a Set. It automatically deduplicates, which keeps your queries clean and your result sets manageable.

Apex Code

// ✅ CORRECT - Set automatically removes duplicates
Set<String> uniqueEmails = new Set<String>();
for (Lead lead : trigger.new) {
    if (lead.Email != null) {
        uniqueEmails.add(lead.Email.toLowerCase());
    }
}

Bulkifying Triggers

A well-structured Apex trigger is thin. It collects records and delegates to a handler class. Here’s what a properly bulkified trigger looks like:

Apex Code

trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
    if (Trigger.isBefore) {
        if (Trigger.isInsert) {
            AccountTriggerHandler.beforeInsert(Trigger.new);
        }
        if (Trigger.isUpdate) {
            AccountTriggerHandler.beforeUpdate(Trigger.new, Trigger.oldMap);
        }
    }
    if (Trigger.isAfter) {
        if (Trigger.isInsert) {
            AccountTriggerHandler.afterInsert(Trigger.new);
        }
        if (Trigger.isUpdate) {
            AccountTriggerHandler.afterUpdate(Trigger.new, Trigger.oldMap);
        }
    }
}

The trigger itself has no logic, no queries, no DML. It just routes to a handler. This makes your code testable, maintainable, and bulk-safe by default.

Key trigger bulkification rules:

  • Always iterate over Trigger.new or Trigger.newMap — never assume a single record
  • Use Trigger.oldMap for field-change comparisons (keyed by ID, instant lookups)
  • Pass the full List<SObject> to handler methods, not individual records
  • Collect all IDs and perform queries outside of any loops

Bulkifying Classes

Your handler and utility classes need to follow the same rules. The pattern is: accept collections, process in bulk, return collections.

Apex Code

public class AccountTriggerHandler {

    public static void afterInsert(List<Account> newAccounts) {
        // Step 1: Collect what you need
        Set<Id> accountIds = new Set<Id>();
        for (Account acc : newAccounts) {
            accountIds.add(acc.Id);
        }

        // Step 2: Query once in bulk
        Map<Id, List<Opportunity>> oppsByAccount = new Map<Id, List<Opportunity>>();
        for (Opportunity opp : [SELECT Id, AccountId, StageName 
                                  FROM Opportunity 
                                  WHERE AccountId IN :accountIds]) {
            if (!oppsByAccount.containsKey(opp.AccountId)) {
                oppsByAccount.put(opp.AccountId, new List<Opportunity>());
            }
            oppsByAccount.get(opp.AccountId).add(opp);
        }

        // Step 3: Process using the map
        List<Account> accountsToUpdate = new List<Account>();
        for (Account acc : newAccounts) {
            List<Opportunity> opps = oppsByAccount.get(acc.Id);
            if (opps != null && opps.size() > 0) {
                accountsToUpdate.add(new Account(Id = acc.Id, Has_Opportunities__c = true));
            }
        }

        // Step 4: Single DML statement
        if (!accountsToUpdate.isEmpty()) {
            update accountsToUpdate;
        }
    }
}

Notice the structure: collect → query once → process with map → single DML. That’s the pattern every bulkified Apex class should follow.


Testing Bulk Operations

The 200-record Test

Salesforce’s best practice — and Apex certification requirement — is to always test with 200 records. That’s the maximum batch size a trigger can receive, and it’s the test that catches governor limit issues before they reach production.

Apex Code

@isTest
private class AccountTriggerHandlerTest {

    @isTest
    static void testBulkInsert_200Records() {
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < 200; i++) {
            accounts.add(new Account(Name = 'Test Account ' + i));
        }

        Test.startTest();
        insert accounts; // This fires the trigger with all 200 records
        Test.stopTest();

        // Assert your expected outcomes
        List<Account> inserted = [SELECT Id, Name FROM Account WHERE Name LIKE 'Test Account%'];
        System.assertEquals(200, inserted.size(), 'All 200 accounts should be inserted');
    }
}

If your trigger has any SOQL or DML inside a loop, this test will fail with a governor limit exception — which is exactly the point. Your test environment should catch these issues, not your users in production.

Good bulk tests also verify:

  • No governor limit exceptions at 200 records
  • Logic produces the correct result for all records (not just the first)
  • Edge cases like null values work at scale
  • Mixed scenarios (some records meet conditions, some don’t) handle correctly

Code Examples

Here’s a complete, real-world example combining everything above. This handler creates a follow-up Task for every new high-priority Case — fully bulkified.

Apex Code

public class CaseTriggerHandler {

    public static void afterInsert(List<Case> newCases) {
        List<Task> tasksToCreate = new List<Task>();

        for (Case c : newCases) {
            if (c.Priority == 'High') {
                tasksToCreate.add(new Task(
                    WhatId = c.Id,
                    Subject = 'Follow up on High Priority Case',
                    ActivityDate = Date.today().addDays(1),
                    Status = 'Not Started',
                    Priority = 'High',
                    OwnerId = c.OwnerId
                ));
            }
        }

        if (!tasksToCreate.isEmpty()) {
            insert tasksToCreate; // Single DML for all tasks
        }
    }

    public static void beforeUpdate(List<Case> newCases, Map<Id, Case> oldMap) {
        // Collect IDs of cases that changed status
        Set<Id> changedCaseIds = new Set<Id>();
        for (Case c : newCases) {
            if (c.Status != oldMap.get(c.Id).Status) {
                changedCaseIds.add(c.Id);
            }
        }

        if (changedCaseIds.isEmpty()) return;

        // Single query to get related entitlements
        Map<Id, Entitlement> entitlementsByCase = new Map<Id, Entitlement>();
        for (CaseEntitlement ce : [SELECT CaseId, EntitlementId 
                                    FROM CaseEntitlement 
                                    WHERE CaseId IN :changedCaseIds]) {
            // Map entitlement to case for later lookup
        }

        // Process all records using the map
        for (Case c : newCases) {
            if (changedCaseIds.contains(c.Id)) {
                // Apply your field updates here
            }
        }
    }
}

This follows the collect → query once → process → single DML pattern throughout. No SOQL in loops, no DML in loops, handles any number of records gracefully.


FAQ

What is apex bulkification and why do I need it? Apex bulkification is the process of writing Apex code to handle multiple records in a single transaction efficiently. You need it because Salesforce’s governor limits — like 100 SOQL queries per transaction — will cause your code to fail in production if it queries or performs DML inside loops.

What is the maximum number of records a trigger can receive at once? An Apex trigger can receive up to 200 records in a single transaction batch. This is why testing with exactly 200 records is the standard best practice for validating bulk-safe behavior.

How do I fix SOQL inside a loop? Move the query outside the loop. First, collect all the IDs you need into a Set<Id>, then run one SOQL query using WHERE Id IN :yourSet, and store the results in a Map<Id, SObject> for O(1) lookups inside your loop.

Can I use Database.insert() instead of insert to avoid governor limits? Using Database.insert() with allOrNone = false allows partial success and prevents a single failing record from rolling back the entire batch. However, it doesn’t reduce the number of DML statements — you still need to collect records and insert them in one call.

What’s the difference between bulkification in triggers vs. Batch Apex? Triggers process synchronous batches of up to 200 records and must complete within tight governor limits. Batch Apex is designed for larger datasets (millions of records) and runs asynchronously, with its own governor limits per batch chunk. Bulkification principles apply to both — collect, query once, process, DML once.

Does bulkification affect Apex CPU time limits? Yes. In addition to SOQL and DML limits, Salesforce enforces a 10,000 ms CPU time limit per transaction. Processing records efficiently using maps and collections significantly reduces CPU usage compared to looping through individual records with repeated method calls.

How do I know if my existing code has bulkification issues? Write a test that inserts or updates 200 records and check if it throws a System.LimitException. You can also use Salesforce’s Developer Console or the Apex Replay Debugger to inspect governor limit usage during test execution.