How To Write Bulkified Apex Code In Salesforce
4.9 out of 5 based on 16456 votesLast updated on 30th Sep 2026 26.4K Views
- Bookmark
Salesforce runs many customers on the same servers. To stop one customer's code from using too much, Salesforce sets limits on every transaction.
Salesforce runs many customers on the same servers. To stop one customer's code from using too much, Salesforce sets limits on every transaction. These are called governor limits. If your code goes over a limit, Salesforce stops it with an error.
A lot of developers find this out late. The code works fine when they test one record. Then someone loads 200 records with Data Loader and the code fails. Bulkification is how you avoid that. It means writing your code so it handles many records at once, using a fixed number of queries and updates.
The limits that matter most
| Limit | Synchronous | Asynchronous |
SOQL queries | 100 | 200 |
DML statements | 150 | 150 |
Records returned by SOQL | 50000 | 50000 |
Records processed by DML | 10000 | 10000 |
CPU time | 10000 ms | 60000 ms |
Heap size | 6 MB | 12 MB |
Most errors come from the first two rows. A query or an update placed inside a loop uses up these limits very fast.
What bulkification means
A trigger does not always get one record. These records mainly come through the Data Loader a Flow, an API call, or a user who is editing a single record on the screen. Your code needs to work in the same manner for 1 record or 200
Here the rule is simple: you have to run the queries and updates once per transaction, not per record.
Mistake 1: a query inside a loop
|
trigger AccountTrigger on Account (before update) { for (Account acc : Trigger.new) { List<Contact> contacts = [ SELECT Id FROM Contact WHERE AccountId = :acc.Id ]; acc.Description = 'Has ' + contacts.size() + ' contacts'; } } |
With one account, this runs one query. With 200 accounts, it runs 200 queries, and you get the error "Too many SOQL queries: 101". Salesforce Testing Course with a single record will never show you this problem.
Mistake 2: An update inside a loop
|
for (Contact c : contactsToUpdate) { update c; } |
The result is the same as before. The difference is that this trigger uses one query whether it gets 1 account or 200.
Each of these will pass through the loop that uses one DML statement. After 150 records are processed, the code will fail.
The fixed version
You need to run the query one time, before the loop, and keep the result in a map. After this look for what you may require in the loop.
|
trigger AccountTrigger on Account (before update) { Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>(); for (Contact c : [ SELECT Id, AccountId FROM Contact WHERE AccountId IN :Trigger.newMap.keySet() ]) { if (!contactsByAccount.containsKey(c.AccountId)) { contactsByAccount.put(c.AccountId, new List<Contact>()); } contactsByAccount.get(c.AccountId).add(c); } for (Account acc : Trigger.new) { List<Contact> related = contactsByAccount.get(acc.Id); Integer total = (related == null) ? 0 : related.size(); acc.Description = 'Has ' + total + ' contacts'; } } |
The result will be the same as it was before. The main difference here is that the trigger uses one query, whether this gets 1 account or 200.
When you only need a count
If all you need is the number of contacts, you don't have to load every contact. Ask the database to count them.
|
Map<Id, Integer> contactCounts = new Map<Id, Integer>(); for (AggregateResult ar : [ SELECT AccountId, COUNT(Id) total FROM Contact WHERE AccountId IN :Trigger.newMap.keySet() GROUP BY AccountId ]) { contactCounts.put((Id) ar.get('AccountId'), (Integer) ar.get('total')); } |
It uses less amount of memory, and it matters when one account has number of contacts.
Fixing the update
Add the records to a list while you loop. When the loop ends, update the whole list in one statement.
|
List<Contact> contactsToUpdate = new List<Contact>(); for (Account acc : Trigger.new) { List<Contact> related = contactsByAccount.get(acc.Id); if (related != null) { for (Contact c : related) { c.Description = 'Parent account updated'; contactsToUpdate.add(c); } } }
if (!contactsToUpdate.isEmpty()) { update contactsToUpdate; }
|
Some points about DML:
- Check that the list has records before you update. If it is empty, you save a statement.
- In a before trigger, change the values on Trigger.new directly. You do not need DML for those records because Salesforce saves them after the trigger runs.
- If it is fine for some records to fail, use Database.update(list, false). One bad record will not cancel the rest. Then read the SaveResult list to see which ones failed.
Use Sets and Maps
Looping through a list inside another loop wastes CPU time. A Set removes duplicate values, and a Map lets you find a record by its key straight away.
|
Set<Id> ownerIds = new Set<Id>(); for (Opportunity opp : Trigger.new) { ownerIds.add(opp.OwnerId); }
Map<Id, User> owners = new Map<Id, User>( [SELECT Id, Name, ManagerId FROM User WHERE Id IN :ownerIds] );
for (Opportunity opp : Trigger.new) { User owner = owners.get(opp.OwnerId); // use owner.Name or owner.ManagerId here }
|
If you pass a query into the Map constructor, Salesforce builds a Map with the record Id as the key. It takes one line.
Keep the trigger small
Use one trigger per object and put the real work in a handler class. The trigger only decides which method to call.
|
trigger AccountTrigger on Account (before update, after update) { AccountTriggerHandler handler = new AccountTriggerHandler();
if (Trigger.isBefore && Trigger.isUpdate) { handler.beforeUpdate(Trigger.new, Trigger.oldMap); } if (Trigger.isAfter && Trigger.isUpdate) { handler.afterUpdate(Trigger.new, Trigger.oldMap); } }
|
Each handler method gets the full list of records, so it handles bulk data from the start. It is also easier to write unit tests for a handler than for a trigger.
Stop triggers from running again and again
An update inside a trigger can start the same trigger a second time. A static variable keeps its value for the whole transaction, so you can use it to remember which records are done. Store the record IDs instead of using one true/false flag. A single flag can wrongly skip records when a load is larger than 200, because Salesforce processes those in chunks inside the same transaction.
|
public class TriggerGuard { private static Set<Id> processedIds = new Set<Id>();
public static Boolean shouldProcess(Id recordId) { if (processedIds.contains(recordId)) { return false; } processedIds.add(recordId); return true; } }
|
Invocable methods get lists too
When a Flow calls Apex, Salesforce can send many requests in one call. The same is true when an Agentforce agent runs an Apex action. Your method should accept a list and handle the whole list together.
|
public with sharing class UpdateAccountStatus {
public class Request { @InvocableVariable(required=true) public Id accountId; @InvocableVariable(required=true) public String status; }
@InvocableMethod(label='Update Account Status') public static void updateStatus(List<Request> requests) { Map<Id, String> statusById = new Map<Id, String>(); for (Request r : requests) { statusById.put(r.accountId, r.status); }
List<Account> accounts = [ SELECT Id FROM Account WHERE Id IN :statusById.keySet() ]; for (Account a : accounts) { a.Status__c = statusById.get(a.Id); } update accounts; } }
|
Status__c is a custom field, so change it to match your own org.
Use async Apex for big jobs
Some work is too heavy for a normal transaction. Async Apex has higher limits.
- Queueable Apex is good for follow-up work and jobs that run one after another.
- Batch Apex handles very large data sets by splitting them into chunks. Each chunk gets its own set of limits.
- Scheduled Apex runs a job at a set time.
- Platform Events let you separate the action that starts something from the slower work that happens after.
Async code still needs to be bulkified. Inside a Batch Apex execute method, query and update for the whole chunk at once.
Test with 200 records
A test with one record proves very little. Insert a full batch.
|
@isTest private class AccountTriggerTest {
@isTest static void updatesDescriptionForBulkRecords() { List<Account> accounts = new List<Account>(); for (Integer i = 0; i < 200; i++) { accounts.add(new Account(Name = 'Test Account ' + i)); } insert accounts;
List<Contact> contacts = new List<Contact>(); for (Account a : accounts) { contacts.add(new Contact(LastName = 'Test', AccountId = a.Id)); } insert contacts;
Test.startTest(); update accounts; Test.stopTest();
Account result = [ SELECT Description FROM Account WHERE Id = :accounts[0].Id ]; System.assertEquals('Has 1 contacts', result.Description); } }
|
Test.startTest() and Test.stopTest() give the code inside them a fresh set of limits. You can also print Limits.getQueries() and Limits.getDmlStatements() to see how much your code is using.
Common mistakes
- Putting SOQL, SOSL, or DML inside a for loop.
- Calling a helper method in a loop when that method runs its own query.
- Selecting more fields than you need, which uses extra memory.
- Writing a query with no WHERE clause on a large object.
- Testing with only one record.
- Forgetting that Flows, triggers and other automation in the same transaction all share the same limits.
You May also Read:
Quick checklist
- No SOQL or SOSL inside loops.
- No DML inside loops.
- Queries use IN with a Set of IDs.
- Maps are used for lookups.
- Lists are checked with isEmpty() before DML.
- Only the fields you need are queried.
- Trigger logic sits in a handler class.
- Recursion is controlled by storing record IDs.
- Invocable methods take and process lists.
- Tests use 200 records and have assertions.
How to Learn This Properly
Reading about bulkification is easy. Getting comfortable with it takes practice, and the best way is to write triggers and test classes yourself and see the limit errors first hand.
If you are new to the platform, a Salesforce Course Online is a good place to begin. This will cover everything, including objects, fields, security, automation, and the data model.
After this, the Salesforce Developer Training will about to continue, and you can learn about Apex, SOQL, triggers, Lightning Web Components, and integrations. Also, you can choose the program where you can write code in every class and not one that only focuses on slides.
Why apply for Certification?
If you want to sit for an exam, a Salesforce Certification Course follows the exam syllabus. The Platform Developer I exam asks about the order of execution, trigger context variables and governor limits, so everything in this guide is useful for it.
A Salesforce Administrator Course also helps developers. Validation rules, Flows and other automation run in the same transaction as your Apex, and knowing how they work helps you understand why a limit was hit.
For newer skills, a Salesforce Agentforce Course teaches how to build agents on Salesforce. Agents call Apex through actions, and that Apex has to be bulk-safe, so the basics in this guide apply there too.
Conclusion
Bulkification is about building four simple habits. First, run your query one time instead of once for every record. Second, update all your records together in one step. Third, store your data in a list, set, or map so you can find it quickly when you need it. Finally, test your code with 200 records at once, not just one. If you follow these habits, your code will stay within Salesforce limits and keep working even when your data becomes much bigger.
Subscribe For Free Demo
Free Demo for Corporate & Online Trainings.