Key Points at a Glance
- The Big Three Collections: Use a
Setto guarantee unique IDs. Use aMapto connect a key (like an ID) to a value (like a record) for instant lookups. Use aListto preserve order and perform DML operations. - The Golden Rule: Never place SOQL queries or DML statements (Insert/Update) inside a
forloop. - Targeted Execution: Use
Trigger.oldMapandTrigger.newMapto compare fields. Only run expensive logic when a specific field actually changes. - Data Grouping: Use a
Map<Id, List<ChildObject>>to group thousands of child records under their parent IDs without running separate queries.
Imagine an Apex Trigger must match 200 incoming Deliveries to their assigned Routes and count the parcels.
The Old/Bad Way: Nested loops. For every delivery, the code loops through all 200 routes to find a match. 200 x 200 = 40,000 comparisons per save. The CPU time explodes, and the code reads like a maze.
The New/Good Way:
1. Query the Routes once and store them in a
Map<Id, Route__c>.2. Loop through the Deliveries and match them instantly using
routeMap.get(delivery.Route__c).3. Count them using a
Map<Id, Integer>.The Payoff: You make one fast pass through your records instead of 40,000 sluggish comparisons. The vast majority of bulkification simply boils down to picking the right collection!
Understanding the Bulkification Engine
Collections are the engine that powers bulkified code. If you understand how to use them together, you will rarely hit Salesforce governor limits.
- List: An ordered collection that allows duplicates. You almost always use Lists for bulk DML (e.g.,
insert recordList;). - Set: An unordered collection that enforces uniqueness. It provides a lightning-fast
contains()check. Perfect for gathering record IDs fromTrigger.new. - Map: A key-value pair dictionary. It is the workhorse of Apex bulkification because it allows O(1) instant lookups by ID.
Map.get().
- Rule: Never act on a single record when you can act on a collection.
- Gain: Processing 200 records consumes almost the exact same SOQL/DML limits as processing just 1. Your code scales beautifully.
- Price: Heavy Map usage consumes Heap size (RAM). A Map containing thousands of complex Parent-to-Child records uses real memory.
- Limits: Salesforce enforces a strict 6MB synchronous (12MB async) Heap limit.
- Built-in Tools: Salesforce gives you
Trigger.newMapandTrigger.oldMapfor free. Use them to check what actually changed during an update.
for loop. It seems obvious when you write it for one record, but the moment a Data Loader pushes 150 records into your trigger, the code crashes at record 101 with the dreaded System.LimitException: Too many SOQL queries: 101 error. Always pull queries outside your loops!
Core Q&A and Common Scenarios
A: This pattern is the foundational difference between code that breaks and code that survives a 10,000-record data load.
trigger ContactTrigger on Contact (after insert, after update) {
// 1. Collect parent Ids first. A Set automatically drops duplicates.
Set<Id> accountIds = new Set<Id>();
// 2. Loop to COLLECT only. No SOQL or DML inside this loop!
for (Contact c : Trigger.new) {
if (c.AccountId != null) {
accountIds.add(c.AccountId);
}
}
// 3. Prepare a Map to hold the Accounts we want to update
Map<Id, Account> accountsToUpdate = new Map<Id, Account>();
// 4. Run ONE Aggregate Query for all parents, outside the loop
for (AggregateResult ar : [SELECT AccountId, COUNT(Id) contactCount
FROM Contact
WHERE AccountId IN :accountIds
GROUP BY AccountId]) {
Id accId = (Id) ar.get('AccountId');
Integer count = (Integer) ar.get('contactCount');
// 5. Build the updates in a Map so each parent only appears once
accountsToUpdate.put(accId, new Account(Id = accId, Contact_Count__c = count));
}
// 6. Perform ONE DML statement
if (!accountsToUpdate.isEmpty()) {
update accountsToUpdate.values();
}
}
A: You compare Trigger.newMap against Trigger.oldMap using the record ID.
For each new record in your loop, fetch the old version using Trigger.oldMap.get(record.Id). Compare the specific field (e.g., Status). Only execute your expensive logic if the new value differs from the old value. This is the Apex equivalent of Flow's ISCHANGED formula.
This prevents needless processing and dangerous infinite recursive loops. It’s critical for performance, especially when workflow rules or other triggers cause the same record to be updated multiple times in a single transaction.
A: You build a Map<Id, List<ChildObject>> in a single pass.
First, query all children where the ParentId is in your Set of parent IDs. Then, loop through those query results. As you iterate, check if the parent ID is already a key in your Map. If it isn't, put the parent ID in the Map with a brand new, empty List<ChildObject>. Finally, grab that list from the Map and add() the current child record to it.
Once populated, you can iterate over your parent records and grab all of a parent's children instantly with a single Map.get(parentId). Zero extra queries required.
A: When working with Large Data Volumes (LDV), loading thousands of wide records (records with dozens of custom fields) into memory can quickly blow past the 6MB synchronous heap limit. To fix this:
- Query Only What You Need: Never use
SELECT *or query fields you don't actually use in the code. Less data means smaller heap usage. - Use For-Loops with SOQL: Using the
for (Account acc : [SELECT Id FROM Account])syntax utilizes SOQL query chunking, which helps manage memory better than dumping a massive list into a variable all at once. - Clear Collections: If you no longer need a massive Map or List halfway through your transaction, call
myMap.clear()to free up memory for the garbage collector. - Go Async: If the volume is simply too high, offload the processing to Batch Apex, which chunks the records and provides a higher 12MB heap limit.