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
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.
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
}
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);
}
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 |
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
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.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.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