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

  1. What Are Apex Collections?
  2. Apex List — Ordered, Allows Duplicates
  3. Apex Set — Unordered, No Duplicates
  4. Apex Map — Key-Value, O(1) Lookups
  5. List vs Set vs Map — When to Use Which
  6. Real-World Trigger Pattern Using All Three
  7. 5 Common Collection Mistakes
  8. Collections Interview Questions

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).

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]);
}
DML tip: The DML operations 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

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...
    }
}
Performance: 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');
}
Remember Trigger.newMap: Salesforce automatically provides 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

Q: What is the difference between a List and a Set in Apex?
A List is an ordered collection that allows duplicate values and supports index-based access. A Set is an unordered collection that automatically rejects duplicates and provides O(1) membership testing via contains(). Use a List when order matters, for SOQL results, or for DML input. Use a Set when you need to deduplicate IDs, test membership efficiently, or use a value in a SOQL WHERE IN clause.
Q: Why do we use Map<Id, SObject> in Apex triggers?
The Map<Id, SObject> pattern lets us query all needed parent records in a single SOQL statement (outside the loop) and then look up each one in O(1) constant time while looping through trigger records. This eliminates the need for SOQL inside a loop, which would hit the 100-query governor limit. The Map constructor automatically uses the record's Id as the key when you pass it a List of SObjects from a SOQL query.
Q: Can you put a Map into a SOQL WHERE clause?
Not directly. You use either the Map's keySet() (which returns a Set<Id>) or Map.values() depending on what you need. For example: 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.
Q: What is Trigger.newMap and when is it available?
Trigger.newMap is a Map<Id, SObject> of all records being inserted or updated, keyed by Id. It is only available in after-insert, after-update, before-update, and after-undelete contexts — not in before-insert (because records don't have IDs yet before they are saved). Trigger.oldMap is available in update and delete contexts and contains the pre-change state of each record, which is essential for field-change detection.
Q: What happens when you call Map.put() with a key that already exists?
The existing value is overwritten silently. put() returns the old value (which you can capture if needed), but no error or exception is thrown. This behavior is intentional and useful when you want to build a map where later values should replace earlier ones. If you want to preserve existing entries, always check containsKey() before calling put().
Q: How do you group records by a field value using a Map?
Use a Map<FieldType, List<SObject>>. Loop through the records, check containsKey() for the group key, initialize a new List if missing, then add the record to the list. Example: 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().
Q: Is List.contains() or Set.contains() faster?
Set.contains() is dramatically faster. List.contains() performs a linear scan — it checks each element until it finds a match, making it O(n). Set.contains() uses a hash table internally and returns in O(1) constant time regardless of the set's size. In a loop over hundreds of records, using List.contains() for membership testing can cause significant CPU time consumption. Always use a Set when you need fast membership testing.
Q: Can you add a null value to a Set or as a Map key?
Yes — Apex allows null as a Set element and as a Map key. However, this is almost always a bug in trigger code. If a Contact has no AccountId (AccountId is null), and you add null to your Set of account IDs, your SOQL query WHERE Id IN :accountIds will include a null, which Salesforce handles gracefully (null Id matches nothing). But using null as a Map key and then calling map.get(null) returns the value stored under the null key — which can lead to subtle bugs. Always guard with null checks before adding to your ID collections: 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