Bulk‑Safe Apex Triggers: Avoiding Governor Limits When Updating Related Records
Learn why a naive Apex trigger that updates a parent record for each child can hit SOQL limits, and how to rewrite it using bulk‑safe patterns that stay within governor limits.
21 Nov 2025, 12:04 UTC

The Problem: SOQL Limits in a Naive Trigger
When an Apex trigger is written to handle one record at a time, it often runs a SOQL query inside a loop that processes Trigger.new. If the loop executes for 200 records, the trigger issues 200 separate queries and quickly hits the governor limit of 100 SOQL queries per transaction. This manifests as a Too many SOQL queries error and the transaction is rolled back.
Why Bulk Context Matters
Apex triggers always receive collections of records in the context variables Trigger.new, Trigger.old, Trigger.newMap and Trigger.oldMap. The platform invokes the trigger once per transaction, passing all records that caused the event. Therefore any logic that assumes a single record must be rewritten to iterate over these collections.
Bulk‑Safe Pattern Overview
- Gather the IDs you need from Trigger.new into a Set.
- Perform a single SOQL query that returns all related records, storing them in a Map keyed by ID.
- Iterate over Trigger.new again, using the Map to look up each related record and collect updates into a list.
- Execute one DML statement on the list of updates (if the list is not empty).
Worked Example: Contact‑to‑Account Update
// Naive version (unsafe)
trigger ContactUpdate on Contact (after insert, after update) {
for (Contact c : Trigger.new) {
Account acct = [SELECT Id, Industry FROM Account WHERE Id = :c.AccountId LIMIT 1];
if (acct.Industry != c.Description) {
acct.Industry = c.Description;
update acct;
}
}
}
The above trigger queries the Account for each Contact, causing one SOQL query per iteration.
// Bulk‑safe version
trigger ContactUpdateBulk on Contact (after insert, after update) {
Set accountIds = new Set();
for (Contact c : Trigger.new) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
Map accountsById = new Map(
[SELECT Id, Industry FROM Account WHERE Id IN :accountIds]
);
List accountsToUpdate = new List();
for (Contact c : Trigger.new) {
Account acct = accountsById.get(c.AccountId);
if (acct != null && acct.Industry != c.Description) {
acct.Industry = c.Description;
accountsToUpdate.add(acct);
}
}
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate;
}
}
The bulk‑safe version queries Account once, then updates all affected Accounts in a single DML statement.
Trade‑offs and Limitations
- Heap usage grows with the number of distinct parent IDs collected; processing tens of thousands of records may approach the heap limit.
- When using DML statements that allow partial success, you must inspect SaveResult objects to detect and handle failures; otherwise errors can be silently ignored.
How to Verify Bulk‑Safe Behavior
- Create or refresh a sandbox and confirm you have the Author Apex permission.
- Deploy the naive trigger (for example with VS Code: sfdx force:source:push or via Developer Console).
- Use Data Loader or the Salesforce UI to update 200 Contact records.
- Open the Developer Console, choose Debug > Open Execute Anonymous Window, run a harmless statement such as System.debug('test'); then check Debug > Logs for the most recent log.
- In the log’s Execution Overview you will see a SOQL query count far above the limit and the operation will fail with a Too many SOQL queries error.
- Replace the trigger with the bulk‑safe version, redeploy, repeat the same 200‑record update.
- Again inspect the log; the Execution Overview should show 1 SOQL query and 1 DML statement, and the transaction should complete without limit errors.
By following these steps you can confirm that the trigger stays within governor limits while still performing the required business logic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.