Bulkification is the single most important concept in Salesforce Apex development. It separates developers who write code that works on one record from developers who write code that works correctly under real production conditions — data loads, integrations, and automation that process hundreds or thousands of records at once. If your Apex trigger or class is not bulkified, it is not production-ready, full stop.

Salesforce enforces strict per-transaction governor limits to protect the shared, multi-tenant infrastructure. The moment your non-bulkified trigger encounters a batch of 101 records, it will throw a System.LimitException: Too many SOQL queries: 101 and roll back the entire transaction. No data gets saved. Users see an error. This guide will make sure that never happens in code you write.

Table of Contents

  1. What Is Bulkification?
  2. What Happens Without Bulkification
  3. The Six Golden Rules of Bulkification
  4. Using Maps Efficiently
  5. Full Bulkified Trigger Pattern
  6. Governor Limits Reference Table
  7. How to Test Bulkification
  8. Bulkification Interview Questions

What Is Bulkification?

Bulkification means writing Apex code that correctly handles a batch of records — not just one at a time. In Salesforce, triggers can fire with up to 200 records in a single transaction. Data Import Wizard, Data Loader, external integrations via the API, and even Process Builder / Flow all operate in batches. Your code must handle that entire batch within the governor limits of a single transaction.

The core idea is simple: instead of performing operations (SOQL queries, DML statements) once per record inside a loop, you collect all your input, do your work in bulk, and then process the results. This keeps SOQL queries and DML statements at a count of 1 regardless of whether 1 or 200 records are in the batch.

Key principle: Think in sets of records, not individual records. Every piece of Apex you write should answer the question: "What happens when 200 of these come through at once?"

What Happens Without Bulkification

Let's look at a trigger that appears to work perfectly when tested with a single record — but fails the moment more than 100 records are processed.

The Bad Trigger: SOQL Inside a Loop

// BAD: This trigger will fail with 101+ records
trigger ContactTrigger on Contact (before insert) {

    for (Contact c : Trigger.new) {
        // SOQL query INSIDE the loop — one query per contact!
        Account parentAccount = [
            SELECT Id, Name, Industry
            FROM Account
            WHERE Id = :c.AccountId
            LIMIT 1
        ];

        if (parentAccount.Industry == 'Technology') {
            c.Title = 'Tech Contact';
        }
    }

    // If 101 contacts are inserted in one batch:
    // → 101 SOQL queries executed
    // → Governor limit is 100 SOQL queries
    // → System.LimitException thrown
    // → Entire transaction rolled back
    // → User sees an error, NOTHING gets saved
}
This is the most common Apex anti-pattern. The trigger above works fine in developer testing with 1-5 records. It silently breaks in production during a CSV import of 500 contacts. The failure is guaranteed, not theoretical.

The Good Trigger: One SOQL Before the Loop

// GOOD: Bulkified — only 1 SOQL query regardless of batch size
trigger ContactTrigger on Contact (before insert) {

    // Step 1: Collect all parent Account IDs from the batch
    Set<Id> accountIds = new Set<Id>();
    for (Contact c : Trigger.new) {
        if (c.AccountId != null) {
            accountIds.add(c.AccountId);
        }
    }

    // Step 2: One SOQL query outside the loop — fetches ALL needed accounts
    Map<Id, Account> accountMap = new Map<Id, Account>(
        [SELECT Id, Name, Industry FROM Account WHERE Id IN :accountIds]
    );

    // Step 3: Process each contact using the map — no SOQL in the loop
    for (Contact c : Trigger.new) {
        if (c.AccountId != null && accountMap.containsKey(c.AccountId)) {
            Account parentAccount = accountMap.get(c.AccountId);
            if (parentAccount.Industry == 'Technology') {
                c.Title = 'Tech Contact';
            }
        }
    }

    // Result: 1 SOQL query regardless of whether 1 or 200 contacts
    // are in the batch. Governor limits are safe.
}

The transformation is always the same three steps: collect IDs into a Set, query with WHERE Id IN :setOfIds and store results in a Map<Id, SObject>, then loop through the trigger records and use map.get(record.LookupId) for O(1) lookups. Memorize this pattern — it appears on every Salesforce developer assessment.

The Six Golden Rules of Bulkification

Rule 1: Never Put SOQL Inside a For Loop

This is the most frequently violated rule. Every SOQL query inside a loop multiplies by the batch size. With 200 records, you exhaust the 100-query limit after just 51 records. Move all SOQL outside loops. Use WHERE Id IN :setOfIds to fetch all needed records at once.

Rule 2: Never Put DML Inside a For Loop

DML statements (insert, update, delete, upsert) have a limit of 150 per transaction. Putting DML inside a loop means one statement per record. Collect all records that need DML into a List, then perform a single DML statement after the loop:

// BAD
for (Contact c : contactsToUpdate) {
    c.Description = 'Updated';
    update c; // one DML per contact — fails at 151 contacts
}

// GOOD
List<Contact> toUpdate = new List<Contact>();
for (Contact c : contactsToUpdate) {
    c.Description = 'Updated';
    toUpdate.add(c);
}
update toUpdate; // one DML for the entire batch — always safe

Rule 3: Use Maps to Avoid Nested Loops

Nested loops (a loop inside a loop) are an O(n²) performance problem and often indicate that SOQL is being used to simulate a join. Use a Map<Id, SObject> built from a single SOQL query to replace the inner loop with a O(1) map lookup.

// BAD: O(n²) — nested loop comparing two lists
for (Contact c : contacts) {
    for (Account a : accounts) {
        if (a.Id == c.AccountId) {
            c.Title = a.Industry + ' Rep';
        }
    }
}

// GOOD: O(n) — map lookup
Map<Id, Account> accountMap = new Map<Id, Account>(accounts);
for (Contact c : contacts) {
    if (accountMap.containsKey(c.AccountId)) {
        c.Title = accountMap.get(c.AccountId).Industry + ' Rep';
    }
}

Rule 4: Collect Records Into Lists Before DML

Before any insert, update, or delete, accumulate all records that need the operation into a List. Only perform the DML after the loop. This applies to every type of DML: creating child records, updating related records, inserting log records, everything.

Rule 5: Use Sets to Deduplicate IDs

A Set automatically rejects duplicates. When collecting lookup IDs from a batch of records, always use a Set<Id> rather than a List<Id>. If ten contacts reference the same account, the Set ensures you only query that account once, keeping your SOQL result set small and your query efficient.

Rule 6: Always Assume 200+ Records

Never write a trigger with the assumption that only one record will come through. Even if your business process currently creates contacts one at a time through the UI, a future data migration, integration, or admin decision can send 200 records through the same trigger. Write for 200 from day one.

Using Maps Efficiently

The Map<Id, SObject> pattern is the cornerstone of bulkified Apex. Understanding it deeply will make you a faster, more confident developer.

When you pass a List of SObjects to the Map constructor, Salesforce automatically uses the Id field as the key:

// Building a Map from a SOQL result — the Id is used as the key automatically
Map<Id, Account> accountMap = new Map<Id, Account>(
    [SELECT Id, Name, Industry, AnnualRevenue FROM Account WHERE Id IN :accountIds]
);

// Lookup by Id — constant time O(1), no loop needed
Account acc = accountMap.get(someContactAccountId);

// Safe pattern — always check before get()
if (accountMap.containsKey(someId)) {
    Account a = accountMap.get(someId);
    // use a...
}

// Get all keys (Set of Ids)
Set<Id> allAccountIds = accountMap.keySet();

// Get all values (List of Accounts)
List<Account> allAccounts = accountMap.values();

// Map from non-Id field — build it yourself
Map<String, Account> byName = new Map<String, Account>();
for (Account a : [SELECT Id, Name FROM Account WHERE Name IN :nameSet]) {
    byName.put(a.Name, a);
}
Performance tip: Always check containsKey() before calling get(). If the key does not exist, get() returns null, and accessing a field on that null reference will throw a NullPointerException — one of the most common runtime errors in Apex.

Full Bulkified Trigger Pattern

Here is a complete, production-quality bulkified trigger that follows all six golden rules. It handles before-insert and after-insert on Contact, updates a field based on the parent Account, and creates a related Task for each new contact.

trigger ContactTrigger on Contact (before insert, after insert) {

    if (Trigger.isBefore && Trigger.isInsert) {
        ContactTriggerHandler.beforeInsert(Trigger.new);
    }

    if (Trigger.isAfter && Trigger.isInsert) {
        ContactTriggerHandler.afterInsert(Trigger.new);
    }
}

// ContactTriggerHandler.cls
public with sharing class ContactTriggerHandler {

    public static void beforeInsert(List<Contact> newContacts) {
        // Rule 5: Collect parent IDs with a Set (auto-deduplicates)
        Set<Id> accountIds = new Set<Id>();
        for (Contact c : newContacts) {
            if (c.AccountId != null) {
                accountIds.add(c.AccountId);
            }
        }

        if (accountIds.isEmpty()) return;

        // Rule 1: One SOQL outside the loop
        Map<Id, Account> accountMap = new Map<Id, Account>(
            [SELECT Id, Industry, Type FROM Account WHERE Id IN :accountIds]
        );

        // Rule 3: Map lookup — no nested loop
        for (Contact c : newContacts) {
            if (c.AccountId != null && accountMap.containsKey(c.AccountId)) {
                Account parent = accountMap.get(c.AccountId);
                c.Department = parent.Industry;
                if (parent.Type == 'Customer') {
                    c.LeadSource = 'Existing Customer';
                }
            }
        }
        // No DML needed — before trigger field changes save automatically
    }

    public static void afterInsert(List<Contact> newContacts) {
        // Rule 4: Collect records into a List before DML
        List<Task> tasksToInsert = new List<Task>();

        for (Contact c : newContacts) {
            Task t = new Task();
            t.Subject = 'Welcome call for ' + c.FirstName;
            t.WhoId = c.Id;
            t.ActivityDate = Date.today().addDays(3);
            t.Status = 'Not Started';
            t.Priority = 'Normal';
            tasksToInsert.add(t);
        }

        // Rule 2: One DML outside the loop
        if (!tasksToInsert.isEmpty()) {
            insert tasksToInsert;
        }
    }
}

Governor Limits Reference Table

These are the per-transaction limits most relevant to trigger development. Hitting any one of these throws a LimitException and rolls back the transaction.

Resource Synchronous Limit Asynchronous Limit How to Avoid Hitting It
SOQL Queries 100 200 Never put SOQL in loops; use Maps
SOQL Rows Retrieved 50,000 50,000 Use LIMIT clauses; query only needed fields
DML Statements 150 150 Collect into Lists; one DML per operation
DML Rows 10,000 10,000 Use Batch Apex for large datasets
CPU Time 10,000 ms 60,000 ms Avoid nested loops; use Maps; optimize algorithms
Heap Size 6 MB 12 MB Nullify large collections when done; avoid loading full records
Callouts 100 100 Use @future or Queueable for callouts from triggers
Future Method Calls 50 Call once per transaction with a Set of IDs
Pro tip: Use System.Limits methods during development to see how close you are to limits: System.debug(Limits.getQueries() + ' of ' + Limits.getLimitQueries());. This is invaluable for diagnosing performance problems in complex triggers.

How to Test Bulkification

A trigger test that only inserts one record is not a real test of bulkification. Salesforce Platform Developer I certification requires that all triggers handle bulk operations correctly, and the exam will have questions specifically about this. Your test class must prove the trigger works with 200 records.

@IsTest
public class ContactTriggerHandlerTest {

    @TestSetup
    static void makeData() {
        // Create one Account to be the parent for all test contacts
        Account testAccount = new Account(
            Name = 'Bulk Test Account',
            Industry = 'Technology',
            Type = 'Customer'
        );
        insert testAccount;
    }

    @IsTest
    static void testBulkInsert_200Contacts() {
        Account acc = [SELECT Id FROM Account LIMIT 1];

        // Build 200 contacts — this is the bulk test
        List<Contact> contacts = new List<Contact>();
        for (Integer i = 0; i < 200; i++) {
            contacts.add(new Contact(
                FirstName = 'Bulk',
                LastName = 'Contact ' + i,
                Email = 'bulk' + i + '@test.com',
                AccountId = acc.Id
            ));
        }

        Test.startTest();
        insert contacts; // Trigger fires with all 200 — should not throw LimitException
        Test.stopTest();

        // Verify results
        List<Contact> inserted = [SELECT Id, Department FROM Contact WHERE AccountId = :acc.Id];
        System.assertEquals(200, inserted.size(), 'Should have inserted 200 contacts');

        // Verify the before-insert logic ran for all contacts
        for (Contact c : inserted) {
            System.assertEquals('Technology', c.Department,
                'Department should be set from parent Account Industry');
        }

        // Verify after-insert tasks were created for all contacts
        List<Task> tasks = [SELECT Id FROM Task WHERE WhoId IN :inserted];
        System.assertEquals(200, tasks.size(), 'Should have created one Task per contact');
    }

    @IsTest
    static void testSingleInsert_StillWorks() {
        Account acc = [SELECT Id FROM Account LIMIT 1];

        Test.startTest();
        insert new Contact(
            FirstName = 'Single',
            LastName = 'Contact',
            AccountId = acc.Id
        );
        Test.stopTest();

        Contact c = [SELECT Department FROM Contact WHERE LastName = 'Contact'];
        System.assertEquals('Technology', c.Department);
    }
}

Notice that the test creates exactly 200 contacts — the maximum batch size for a trigger. This proves the trigger will not fail under real load. If the trigger had any SOQL inside a loop, this test would fail with a LimitException, catching the bug before it reaches production.

Bulkification Interview Questions

Q: What is bulkification and why is it important in Salesforce Apex?
Bulkification means writing Apex code that processes a batch of records (up to 200 in a trigger) within Salesforce's per-transaction governor limits. It matters because Salesforce is a multi-tenant platform — governor limits prevent any single tenant from monopolizing shared resources. Non-bulkified code will throw a LimitException and roll back the transaction when more than 100 records are processed, causing data loss and user errors in production.
Q: What is the maximum number of records that can fire a trigger at once?
A trigger can fire with up to 200 records in a single batch. This is the default chunk size for the Salesforce API and Data Loader. Batch Apex processes records in configurable chunks of up to 2,000 records per execute() call, but the 200-record limit applies specifically to DML-triggered Apex (triggers).
Q: What happens if you put a SOQL query inside a trigger's for loop that processes 150 contacts?
Salesforce will execute one SOQL query per iteration of the loop — 150 queries total. The governor limit for synchronous SOQL queries is 100 per transaction. The 101st query will throw System.LimitException: Too many SOQL queries: 101. The entire transaction will roll back, no records will be saved, and the user will see an error message. The fix is to collect all IDs before the loop, execute one SOQL with a WHERE IN clause, store results in a Map, and do O(1) map lookups inside the loop.
Q: Why do we use Map<Id, SObject> in Apex triggers instead of just a List?
A Map gives us O(1) key-based lookup by Id, which eliminates the need for a nested loop to find matching records. If you stored the SOQL results in a List and needed to match them to trigger records, you would need an inner loop — O(n²) complexity. With a Map, you call map.get(record.LookupId) in constant time regardless of how many records are in the batch. It also lets you check existence with containsKey() before accessing, preventing NullPointerExceptions.
Q: How do you write a test class that proves your trigger is correctly bulkified?
Insert exactly 200 records in your test method — the maximum trigger batch size. Wrap the DML in Test.startTest() / Test.stopTest() to get fresh governor limit counters. If the trigger contains SOQL or DML in a loop, the test will throw a LimitException, proving the bug. If it passes, it proves the trigger handles the maximum batch size within all governor limits. Additionally, use System.assertEquals to verify that all 200 records were processed correctly — not just that no exception was thrown.

Practice Bulkification Problems on ApexArena

Understanding the theory is step one. ApexArena has hands-on coding challenges specifically focused on bulkification, governor limits, and trigger patterns — practice until the Map pattern is second nature.

Start Practicing Free