Skip to main content

Master Salesforce Apex Bulkification: Lists, Sets & Maps Explained

๐Ÿ’ฌ In plain words: Bulkification is writing code that safely handles 1 record or 200 records using the exact same amount of database queries. The secret formula is always a three-step dance: 1) Collect IDs into a Set. 2) Query the database ONCE into a Map. 3) Loop through your records in memory and look up related data instantly using that Map. Maps are the ultimate performance hack—query once, read a thousand times for free.

Key Points at a Glance

  • The Big Three Collections: Use a Set to guarantee unique IDs. Use a Map to connect a key (like an ID) to a value (like a record) for instant lookups. Use a List to preserve order and perform DML operations.
  • The Golden Rule: Never place SOQL queries or DML statements (Insert/Update) inside a for loop.
  • Targeted Execution: Use Trigger.oldMap and Trigger.newMap to 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.
Apex Collections, Maps and Bulkification Patterns
๐ŸŽฌ Real-Life Example: The Map That Replaced 40,000 Comparisons
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 from Trigger.new.
  • Map: A key-value pair dictionary. It is the workhorse of Apex bulkification because it allows O(1) instant lookups by ID.
๐Ÿง  Set, Map, Loop: This is the holy trinity of Bulkification. Use a Set to collect unique keys → Run ONE query to populate a Map → Loop through your records using Map.get().
๐Ÿงญ 360 Card — Collections & Bulkification
  • 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.newMap and Trigger.oldMap for free. Use them to check what actually changed during an update.
⚠ INTERVIEW TRAP: Querying inside a 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

Q: Write the classic bulkified trigger pattern that updates each Account with data from its Contacts. Explain each collection's role.
๐ŸŽฏ Say this first: "Collect the Account IDs into a Set. Run one SOQL aggregate query to get Contact counts and store them in a Map. Loop through the accounts in memory, and perform one DML update at the end."

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();
    }
}
Q: How do you detect which specific field changed on an update, so you only run logic when necessary?

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.

Q: You need to process thousands of child records grouped by their parent, but you can't run a query for each parent. What is the pattern?

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.

Q: What happens if your Maps get too large and hit the Heap Size limit?

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.