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
- What is Bulkification?
- Why Bulkification Matters
- Common Anti-patterns to Avoid
- Bulkification Patterns That Work
- Bulkifying Triggers
- Bulkifying Classes
- Testing Bulk Operations
- Code Examples
- 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 Limit | Threshold |
|---|---|
| SOQL queries per transaction | 100 |
| DML statements per transaction | 150 |
| Records processed by DML | 10,000 |
| CPU time | 10,000 ms |
| Heap size | 6 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.

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.

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.

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.neworTrigger.newMap— never assume a single record - Use
Trigger.oldMapfor 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.