Apex collections — List, Set, and Map — are the backbone of every well-written Salesforce trigger, batch job, and service class. You cannot write bulkified code without them. You cannot avoid governor limits without them. Every Apex interview question about triggers or performance will require you to use them correctly. If you learn nothing else about Apex development this year, learn collections thoroughly.
This guide covers all three collection types in depth: their syntax, their most important methods, how to choose between them, and real-world patterns you will use on the job every day. We also walk through the five most common collection mistakes that developers make — mistakes that cause NullPointerExceptions, logic errors, and performance problems in production. By the end, you will have a clear mental model for reaching for the right collection type instantly.
Table of Contents
What Are Apex Collections?
Apex provides three built-in collection types: List, Set, and Map. They are generic types — you specify the data type they hold when you declare them. Collections can hold primitives (Integer, String, Boolean, Id), SObjects (Account, Contact, Custom_Object__c), or even other collections (List of Lists, Map of Maps).
- List<T> — An ordered sequence of elements. Duplicates allowed. Elements are accessed by integer index (zero-based). Similar to an array in other languages, but dynamically sized.
- Set<T> — An unordered collection of unique elements. Duplicates are silently ignored on add. No index access. Best for deduplication and fast membership testing.
- Map<K, V> — A collection of key-value pairs. Keys are unique; adding a duplicate key overwrites the existing value. O(1) lookup by key. The most powerful collection for trigger patterns.
Collections are also the primary tool for staying within Salesforce governor limits. Instead of one SOQL query per record (which quickly exhausts the 100-query limit), you collect all the IDs you need into a Set, run one query using WHERE Id IN :mySet, and store the results in a Map for fast lookup. This pattern appears in every professionally-written Apex trigger.
Apex List
A List is the most familiar collection type for developers coming from any language. It is ordered (elements stay in insertion order), allows duplicate values, and supports index-based access.
Syntax and Initialization
// Declare and initialize an empty List
List<String> countries = new List<String>();
// Declare with initial values
List<Integer> scores = new List<Integer>{ 95, 87, 72, 100 };
// List of SObjects
List<Account> accounts = new List<Account>();
// List from a SOQL query (returns List<SObject> automatically)
List<Contact> contacts = [SELECT Id, Name, Email FROM Contact LIMIT 50];
Essential List Methods
List<String> fruits = new List<String>{ 'Apple', 'Banana', 'Cherry' };
// add() — append an element to the end
fruits.add('Durian'); // ['Apple','Banana','Cherry','Durian']
// add(index, element) — insert at a specific position
fruits.add(1, 'Avocado'); // ['Apple','Avocado','Banana','Cherry','Durian']
// get() — retrieve by index (zero-based)
String first = fruits.get(0); // 'Apple'
String also = fruits[0]; // shorthand — same result
// set() — replace value at index
fruits.set(0, 'Apricot'); // ['Apricot','Avocado','Banana','Cherry','Durian']
// size() — number of elements
Integer count = fruits.size(); // 5
// remove() — remove by index
fruits.remove(0); // removes 'Apricot'
// contains() — membership test (linear search — O(n))
Boolean hasBanana = fruits.contains('Banana'); // true
// isEmpty() — check if empty
Boolean empty = fruits.isEmpty(); // false
// clear() — remove all elements
fruits.clear(); // []
// sort() — sort in place (ascending, using natural order)
List<Integer> nums = new List<Integer>{ 5, 2, 8, 1 };
nums.sort(); // [1, 2, 5, 8]
// addAll() — add all elements from another List or Set
List<String> more = new List<String>{ 'Elderberry', 'Fig' };
fruits.addAll(more);
// Iterate with for-each (most common pattern)
for (String fruit : fruits) {
System.debug(fruit);
}
// Iterate with index when you need position
for (Integer i = 0; i < fruits.size(); i++) {
System.debug(i + ': ' + fruits[i]);
}
insert, update, delete, and upsert all accept a List of SObjects. Always collect your records into a List and perform a single DML call — never DML inside a loop.
When to Use a List
- When order matters (you need to process records in the order they were inserted or returned by SOQL)
- When you need index-based access (
list[0],list[i]) - When duplicates are acceptable or expected
- When preparing records for DML (
insert myList) - For SOQL results — SOQL always returns a List
Apex Set
A Set stores unique values only. Attempting to add a duplicate silently does nothing — no error, no exception, just the Set remains unchanged. This property makes Set invaluable for collecting Salesforce record IDs from a trigger batch, where multiple records might reference the same parent ID and you only want to query each parent once.
Syntax and Initialization
// Empty Set
Set<Id> accountIds = new Set<Id>();
// Initialize with values
Set<String> statusValues = new Set<String>{ 'Active', 'Pending', 'Closed' };
// Build from a List (removes duplicates automatically)
List<String> rawValues = new List<String>{ 'A', 'B', 'A', 'C', 'B' };
Set<String> uniqueValues = new Set<String>(rawValues); // {'A','B','C'}
Essential Set Methods
Set<Id> accountIds = new Set<Id>();
// add() — add a value; returns true if added, false if duplicate
accountIds.add('001xx000003GYn5AAG'); // added
accountIds.add('001xx000003GYn5AAG'); // duplicate — silently ignored, returns false
// contains() — O(1) membership test (much faster than List.contains())
Boolean exists = accountIds.contains('001xx000003GYn5AAG'); // true
// remove() — remove a specific value
accountIds.remove('001xx000003GYn5AAG');
// size() — number of unique elements
Integer count = accountIds.size();
// isEmpty() — check if empty
Boolean empty = accountIds.isEmpty();
// addAll() — add all elements from a List or another Set
Set<Id> moreIds = new Set<Id>{ '001xx000003GYn6AAG' };
accountIds.addAll(moreIds);
// Iterate (no index — Sets are unordered)
for (Id accId : accountIds) {
System.debug(accId);
}
// Use directly in SOQL — extremely powerful
List<Account> accounts = [SELECT Id, Name FROM Account WHERE Id IN :accountIds];
// Convert Set to List (needed when you want index access or List methods)
List<Id> idList = new List<Id>(accountIds);
The Canonical Trigger Pattern with Set
trigger OpportunityTrigger on Opportunity (after insert, after update) {
// Step 1: Collect all Account IDs from the trigger batch
// Using Set<Id> ensures we don't query the same Account twice
// even if 50 Opportunities all belong to the same Account
Set<Id> accountIds = new Set<Id>();
for (Opportunity opp : Trigger.new) {
if (opp.AccountId != null) {
accountIds.add(opp.AccountId); // duplicates silently ignored
}
}
// Step 2: One SOQL for ALL accounts — no matter how many opps or accounts
if (!accountIds.isEmpty()) {
List<Account> accounts =
[SELECT Id, Name FROM Account WHERE Id IN :accountIds];
// ...process...
}
}
Set.contains() is O(1) — it uses a hash table internally. List.contains() is O(n) — it scans every element. For membership testing in loops, always use a Set, never a List.
Apex Map
Map is the most powerful collection in Apex, and the Map<Id, SObject> pattern is the most important pattern in all of Salesforce trigger development. Every interview question about triggers, every code review of a Salesforce project, and every best-practices checklist mentions Maps.
A Map stores key-value pairs. Each key is unique. Given a key, you can retrieve its associated value in O(1) constant time — it does not matter if the Map has 10 entries or 10,000. This is what eliminates nested loops in triggers.
Syntax and Initialization
// Empty Map
Map<Id, Account> accountMap = new Map<Id, Account>();
// Initialize with SOQL — the most common pattern
// The Map constructor automatically uses the Id field as the key
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name, Industry, AnnualRevenue FROM Account WHERE Id IN :accountIds]
);
// Map with String keys
Map<String, String> countryToCode = new Map<String, String>{
'United States' => 'US',
'United Kingdom' => 'GB',
'India' => 'IN'
};
// Map of List values (group records by a field)
Map<String, List<Contact>> contactsByDept = new Map<String, List<Contact>>();
Essential Map Methods
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Name, Industry FROM Account WHERE Id IN :accountIds]
);
// get(key) — retrieve value by key; returns null if key doesn't exist
Account acc = accountMap.get(someId);
// containsKey(key) — ALWAYS check before get() to avoid NullPointerException
if (accountMap.containsKey(someId)) {
Account a = accountMap.get(someId);
System.debug(a.Name);
}
// put(key, value) — add or update an entry
accountMap.put(newAccount.Id, newAccount);
// If the key exists, the old value is REPLACED (not an error)
// keySet() — returns a Set<K> of all keys
Set<Id> allIds = accountMap.keySet();
// values() — returns a List<V> of all values (order not guaranteed)
List<Account> allAccounts = accountMap.values();
// size() — number of key-value pairs
Integer count = accountMap.size();
// isEmpty() — check if map has no entries
Boolean empty = accountMap.isEmpty();
// remove(key) — removes the entry and returns the value (or null)
Account removed = accountMap.remove(someId);
// Trigger.newMap and Trigger.oldMap — built-in Maps Salesforce provides
// These are Map<Id, SObject> of the trigger records
Map<Id, Opportunity> newOppMap = (Map<Id, Opportunity>) Trigger.newMap;
Map<Id, Opportunity> oldOppMap = (Map<Id, Opportunity>) Trigger.oldMap;
Grouping Records with a Map of Lists
// Group contacts by their Account — one query, organized result
List<Contact> contacts = [SELECT Id, Name, AccountId FROM Contact WHERE AccountId IN :accountIds];
Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
for (Contact c : contacts) {
if (!contactsByAccount.containsKey(c.AccountId)) {
contactsByAccount.put(c.AccountId, new List<Contact>());
}
contactsByAccount.get(c.AccountId).add(c);
}
// Now you can process each account's contacts in one place
for (Id accountId : contactsByAccount.keySet()) {
List<Contact> accountContacts = contactsByAccount.get(accountId);
System.debug('Account ' + accountId + ' has ' + accountContacts.size() + ' contacts');
}
Trigger.newMap (Map of new records by Id) and Trigger.oldMap (Map of old records by Id) in after-triggers and update triggers. Use them to detect field changes: if (newOpp.StageName != Trigger.oldMap.get(newOpp.Id).StageName) — no extra Map needed.
List vs Set vs Map — When to Use Which
| Feature | List | Set | Map |
|---|---|---|---|
| Ordered? | Yes (insertion order) | No | No (key-value pairs) |
| Allows duplicates? | Yes | No | Keys: No. Values: Yes |
| Index access? | Yes (list[0]) | No | By key (map.get(id)) |
| Lookup speed | O(n) — linear scan | O(1) — hash | O(1) — hash |
| Best for SOQL results? | Yes | No | Yes (via Map constructor) |
| Best for DML? | Yes | No | No |
| Best for ID deduplication? | No | Yes | No |
| Best for parent-child lookup? | No | No | Yes |
| Use in WHERE IN SOQL? | Yes | Yes | Use keySet() |
Decision guide: Need to query something by ID? Map. Need to deduplicate IDs? Set. Need ordered results, DML input, or SOQL output? List. In a typical trigger: Set to collect IDs, Map to hold SOQL results, List to hold records for DML.
Real-World Trigger Pattern Using All Three
This is the pattern you will see in every professional Salesforce codebase. It uses all three collection types correctly in their ideal roles: Set for ID collection, Map for fast parent record lookup, and List for DML accumulation.
// Scenario: When Contacts are inserted or updated, if their Account's
// Industry is 'Technology', set the Contact's Title to 'Tech Specialist'.
// Also create a follow-up Task for each affected Contact.
trigger ContactTrigger on Contact (after insert, after update) {
ContactTriggerHandler.handleUpsert(Trigger.new, Trigger.oldMap);
}
public with sharing class ContactTriggerHandler {
public static void handleUpsert(
List<Contact> newContacts,
Map<Id, Contact> oldMap
) {
// ===== STEP 1: COLLECT =====
// Use a SET to collect unique parent Account IDs
// (deduplication is automatic — 50 contacts under same account = 1 query)
Set<Id> accountIds = new Set<Id>();
for (Contact c : newContacts) {
// Skip if no parent account
if (c.AccountId == null) continue;
// For updates, only process if AccountId or Title changed
if (oldMap != null) {
Contact old = oldMap.get(c.Id);
if (c.AccountId == old.AccountId && c.Title == old.Title) continue;
}
accountIds.add(c.AccountId);
}
if (accountIds.isEmpty()) return;
// ===== STEP 2: QUERY =====
// Use a MAP built from one SOQL query (never inside the loop)
Map<Id, Account> accountMap = new Map<Id, Account>(
[SELECT Id, Industry FROM Account WHERE Id IN :accountIds]
);
// ===== STEP 3: PROCESS =====
// List to accumulate Tasks for bulk DML
List<Task> tasksToInsert = new List<Task>();
for (Contact c : newContacts) {
if (c.AccountId == null) continue;
if (!accountMap.containsKey(c.AccountId)) continue; // safety check
Account parentAccount = accountMap.get(c.AccountId); // O(1) lookup
if (parentAccount.Industry == 'Technology') {
// Cannot modify after-trigger records — this would need a separate update list
// In a before-trigger, you'd just: c.Title = 'Tech Specialist';
Task t = new Task(
Subject = 'Tech onboarding: ' + c.FirstName + ' ' + c.LastName,
WhoId = c.Id,
OwnerId = c.OwnerId,
Status = 'Not Started',
Priority = 'Normal',
ActivityDate = Date.today().addDays(7)
);
tasksToInsert.add(t); // collect in LIST
}
}
// ===== STEP 4: DML =====
// One DML statement for all Tasks — not one per Contact
if (!tasksToInsert.isEmpty()) {
insert tasksToInsert;
}
}
}
5 Common Collection Mistakes
Mistake 1: Nested Loop Instead of Map Lookup
// BAD — O(n²): inner loop runs for EVERY outer loop iteration
for (Contact c : contacts) {
for (Account a : accounts) {
if (a.Id == c.AccountId) {
c.Description = a.Industry;
}
}
}
// GOOD — O(n): Map built once, lookup is instant
Map<Id, Account> accMap = new Map<Id, Account>(accounts);
for (Contact c : contacts) {
if (accMap.containsKey(c.AccountId)) {
c.Description = accMap.get(c.AccountId).Industry;
}
}
Mistake 2: Using List When Set Should Be Used for IDs
// BAD — List allows duplicates; if 100 contacts share 1 account,
// the List has 100 entries but we only need 1 query
List<Id> accountIdList = new List<Id>();
for (Contact c : Trigger.new) {
accountIdList.add(c.AccountId); // duplicate IDs pile up
}
// The SOQL WHERE Id IN works, but the intent is wrong
// and performance is misleading
// GOOD — Set deduplicates automatically; always use Set for ID collection
Set<Id> accountIdSet = new Set<Id>();
for (Contact c : Trigger.new) {
if (c.AccountId != null) {
accountIdSet.add(c.AccountId); // duplicates silently dropped
}
}
Mistake 3: Not Checking containsKey() Before get()
// BAD — if accountMap doesn't have this ID, get() returns null
// and accessing .Industry throws NullPointerException
Account a = accountMap.get(c.AccountId);
c.Description = a.Industry; // CRASH if a is null
// GOOD — always guard with containsKey()
if (accountMap.containsKey(c.AccountId)) {
Account a = accountMap.get(c.AccountId);
c.Description = a.Industry;
}
// ALSO GOOD — null check (equivalent but less readable)
Account a = accountMap.get(c.AccountId);
if (a != null) {
c.Description = a.Industry;
}
Mistake 4: Modifying a List While Iterating Over It
// BAD — modifying the list you are iterating causes unpredictable behavior
List<String> items = new List<String>{ 'a', 'b', 'c' };
for (String item : items) {
if (item == 'b') {
items.remove(1); // ConcurrentModificationException or skipped elements
}
}
// GOOD — collect items to remove, then remove after iteration
List<String> toRemove = new List<String>();
for (String item : items) {
if (item == 'b') {
toRemove.add(item);
}
}
items.removeAll(toRemove);
Mistake 5: Using Map.values() When Map.keySet() is Needed (or vice versa)
Map<Id, Account> accountMap = new Map<Id, Account>(accounts);
// Map.keySet() — returns Set<Id> of all Account IDs
// Use this when you need the IDs for another SOQL query
Set<Id> ids = accountMap.keySet();
List<Contact> contacts = [SELECT Id FROM Contact WHERE AccountId IN :ids];
// Map.values() — returns List<Account> of all Account records
// Use this when you need to iterate the records themselves
for (Account a : accountMap.values()) {
System.debug(a.Name);
}
// MISTAKE: using values() when you need IDs
// [SELECT Id FROM Contact WHERE AccountId IN :accountMap.values()] — COMPILE ERROR
// A List<Account> is not a valid bind for a WHERE Id IN clause
// MISTAKE: using keySet() when you need the records
// for (Id id : accountMap.keySet()) { id.Name } — COMPILE ERROR
// An Id has no .Name field
Collections Interview Questions
WHERE Id IN :myMap.keySet() queries records whose Ids are keys in the map. You cannot use a Map itself as a bind variable in SOQL — only primitives, Lists, and Sets are valid bind types.Map<String, List<Contact>> byDept — where the key is Department and the value is the list of contacts in that department. This lets you process all records belonging to the same group together with a single outer loop over keySet().if (c.AccountId != null) accountIds.add(c.AccountId);Practice Apex Collections Problems on ApexArena
The best way to master List, Set, and Map is to use them in real coding challenges. ApexArena has dozens of problems specifically designed to build your collection instincts — from beginner to advanced trigger patterns.
Start Practicing Free