Home / apex / apex-collections-patterns
apex-collections-patterns
Use when designing, reviewing, or debugging Apex code that relies on List, Set, or Map collections in triggers, batch classes, or service layers — especially for bulkification, heap management, and safe null handling.
Open the skill folder on GitHub
Apex Collections Patterns
Use this skill when reviewing or writing Apex code that uses List, Set, or Map to aggregate, de-duplicate, or look up SObject data in triggers, batch classes, or service layers. The skill covers safe null handling, heap-efficient accumulation patterns, and idiomatic bulkification using Map<Id, List<SObject>>.
It owns the container decision and the key semantics: choosing List/Set/Map by access pattern, grouping and indexing idioms, sObject and custom-type equality as keys, sorting with Comparable vs Comparator, safe iteration and mutation, and testing collection logic with deterministic ordering. Adjacent skills own the rest: apex/apex-wrapper-class-patterns owns wrapper DTO design, apex/apex-cpu-and-heap-optimization owns CPU-time tuning once the algorithm shape is already right, apex/apex-design-patterns owns the layering these helpers live inside, and apex/soql-fundamentals owns the query that produces the list you are about to index.
Before Starting
Gather this context before working on anything in this domain:
- Is the code running in a trigger (200-record scope), a batch execute() chunk (configurable scope), or synchronous Apex? The heap pressure and loop patterns differ.
- Is the class implementing Database.Stateful? If so, any instance-level Map or List grows across every execute() chunk and can exhaust the heap before the job finishes.
- Are Set intersection or subtraction operations needed? If so, confirm whether mutating the receiver is acceptable —
retainAll()andremoveAll()modify the Set in place. - What is the maximum expected volume of records? A Map keyed on Id with one value per key is O(n); a Map<Id, List<SObject>> with unbounded inner lists can be O(n²) if records share the same key frequently.
Questions to Ask Before Configuring
Ask these before the first new Map<...> is written. Each one maps to a documented behaviour in references/gotchas.md that silently produces wrong data rather than an exception.
| Ask | Why it matters | What a good answer adds |
|---|---|---|
| "What is the access pattern — position, membership, or lookup by key?" | List is index-addressable, a Set is not ("you can't access a set element at a specific index", apexdev L1641), and a Map is the only one with O(1) keyed retrieval | The container, chosen once, instead of a List that later grows a contains() in a loop |
| "Where do the keys come from — record Ids, an external system's strings, or a composite?" | Id normalizes a 15-character value to 18 (apexdev L1255); String does not, and String keys are case-sensitive (apexdev L1697) | Whether Set<Id> or Set<String> is correct, and whether an integration payload will silently produce two entries per record |
| "Can any record in the list be unsaved when the map is built?" | A map key may hold null and a repeated key overwrites the earlier entry (apexdev L1694–L1696), so unsaved records collapse into one slot | Whether new Map<Id, SObject>(list) is safe or the map must be built with an explicit null check |
| "Is a custom class going to be a Map key or a Set element?" | Uniqueness of user-defined key types comes from equals and hashCode you write (apexdev L1700); without them the types compare by reference (apexdev L2135–L2138) | Whether the class needs both methods, and whether its fields must be immutable after insertion |
| "Does anything downstream depend on the order of this collection?" | Set and Map iteration order is deterministic per code path but is not insertion order (apexdev L1642, L1692), and default List.sort() on sObjects uses a fixed field sequence (apexdev L10245–L10258) | Whether a Comparator is required, and what the test should assert instead of "the list has 3 elements" |
| "Will the collection be edited while it is being iterated?" | "Modifying a collection's elements while iterating through that collection is not supported and causes an error" (apexdev L3182–L3183), and keySet() is a live view of the map (apexrefguide L222249) | The temporary collection the removals or additions have to go into |
| "What is the largest realistic volume this collection will hold in one transaction?" | There is no item-count limit on a collection, only the heap ceiling — 6 MB sync, 12 MB async (apexdev L1431, L19577) | The number the bulk test uses, and whether the design needs a per-chunk flush instead of accumulation |
What a proper configuration adds over just doing it: the container is chosen from the access pattern rather than from habit, key semantics are asserted in a test rather than assumed, and the failure modes that produce silently wrong data — a collapsed null key, a duplicated Id string, a mutable custom key — are caught by scripts/check_apex_collections_patterns.py before a reviewer has to notice them.
Core Concepts
Collections Are Bounded By Heap, Not By Item Count
The Apex Developer Guide is explicit: "There is no limit on the number of items a collection can hold. However, there is a general limit on heap size" (apexdev L1431). That ceiling is 6 MB for synchronous transactions and 12 MB for asynchronous ones (apexdev L19577). A Map<Id, List<SObject>> is the standard bulkification container in trigger handlers: one SOQL returns all related records, and the Map groups them by parent Id. Each inner List is a separate allocation on the same budget. In a Database.Stateful batch job, instance-level Map or List fields persist across every execute() call, so the accumulation grows against a ceiling that does not reset even though the per-chunk governor counters do (apexdev L19530–L19531).
Map.get() Returns null — Not an Exception
Map.get(key) "Returns the value to which the specified key is mapped, or null if the map contains no value for this key" (apexrefguide L222556–L222557). Calling .size(), iterating, or dereferencing a field on that null causes a NullPointerException at the use site, not at the get(). Three correct guards, in increasing order of terseness: containsKey() before get(); assign to a local and null-check; or the safe navigation operator, which "short-circuits expressions that attempt to operate on a null value and returns null instead of throwing a NullPointerException" (apexdev L2353–L2355) — accountsById.get(c.AccountId)?.Industry.
Set Mutation — retainAll() and removeAll() Are In-Place
Set.retainAll(otherCollection) "Retains only the elements in this set that are contained in the specified list", returning "true if the original set changed as a result of the call" (apexrefguide L230750–L230751, L230771). Set.removeAll() is the mirror image. Both are destructive to the receiver. If the original Set is needed after the operation, copy it first with new Set<Id>(originalSet). CollectionUtils.intersect() and subtract() in references/code-examples.md §1 wrap exactly this.
Key Semantics Differ By Key Type
| Key type | Uniqueness rule | Grounded at |
|---|---|---|
Id | == is case-sensitive and does not distinguish 15- from 18-character forms; an Id-typed 15-character value is converted to 18 | apexdev L2135–L2136, L1255 |
String | Case-sensitive as a Map key or Set element — two keys differing only by case are distinct entries | apexdev L1697–L1699; apexrefguide L230262–L230263 |
sObject | Determined by comparing the objects' field values; changing the record after insertion breaks the mapping | apexdev L1700–L1704 |
| User-defined class | Determined by the equals and hashCode methods you provide | apexdev L1700, L7044–L7045 |
Decimal | "Two Decimal objects that are numerically equivalent but differ in scale (such as 1.1 and 1.10) generally don't have the same hashcode" | apexdev L1245–L1247 |
keySet() Is a View; values() Is a List
"With the keySet() method, the returned keySet is backed by the map and reflects any changes made to the map, and vice versa" (apexrefguide L222249–L222250, restated at L222682). So for (Id k : m.keySet()) { m.remove(k); } is the documented "modifying a collection while iterating" error, and Set<Id> snapshot = new Set<Id>(m.keySet()) is the fix. values() is documented as returning "a list that contains all the values in the map" with deterministic order (apexrefguide L222901–L222915) and carries no backing note.
Sorting Needs an Explicit Order
List.sort() on sObjects follows a fixed sequence: the sObject type label, then Name, then standard fields alphabetically excluding Id and Name, then custom fields alphabetically (apexdev L10245–L10258). Primitives sort ascending with nulls first (apexrefguide L221858–L221872). For anything else, "You can sort custom types (your Apex classes) if they implement the Comparable interface. Alternatively, a class implementing the Comparator interface can be passed as a parameter to the List.sort method" (apexdev L1537–L1539). The Comparator contract carries one hard obligation: "Your implementation must explicitly handle null inputs in the compare() method to avoid a null pointer exception" (apexrefguide L202283–L202284).
Common Patterns
Map<Id, List<SObject>> for Bulkified Trigger Lookups
When to use: An after-insert or after-update trigger on a child object needs to group child records by their parent Id before performing a single DML or SOQL operation at the parent level.
How it works:
- Query all relevant parent records using the set of parent Ids extracted from
Trigger.new. - Build a
Map<Id, List<Child__c>>by iterating the query results once, usingMap.containsKey()guard beforeMap.get(). - Iterate
Trigger.new, look up each record's parent group from the Map, and accumulate changes. - Perform a single bulkified DML call outside all loops.
Reference the templates/apex/TriggerHandler.cls scaffold for the handler structure. The collection building belongs in the handler's afterInsert() / afterUpdate() methods, not in a trigger body directly — or in CollectionUtils.groupByLookup() from references/code-examples.md §1, which the handler then calls.
Why not the alternative: Querying inside a for loop over Trigger.new runs one SOQL per record, burning the 100-query synchronous limit (apexdev L19544) on any bulk load of 100+ records.
Safe Set Intersection With retainAll()
When to use: A service method needs to find the overlap between two Sets — for example, the set of record Ids that are both in a new batch and in an existing do-not-process exclusion list.
How it works:
- Construct the first Set from the incoming Ids:
Set<Id> incoming = new Set<Id>(triggerIds); - Construct or load the exclusion Set from a SOQL or Custom Metadata query.
- Create a working copy if the original Set must be preserved:
Set<Id> overlap = new Set<Id>(incoming); - Call
overlap.retainAll(exclusionIds);— the result is the intersection in one platform operation. - Subtract from the working set to get records that are NOT excluded:
incoming.removeAll(exclusionIds);
Why not the alternative: Building a new Set by iterating and adding manually is O(n) extra code, allocates additional intermediate objects, and is more likely to introduce off-by-one bugs.
A Composite Key Class Instead of a Nested Map
When to use: A roll-up is keyed on two or more dimensions — owner and stage, account and product family, region and month.
How it works: Write a small final-field class with equals(Object) and hashCode() and use it directly as the Map key. Map<StageOwnerKey, Decimal> replaces Map<Id, Map<String, Decimal>> and removes the inner containsKey guard entirely. See references/code-examples.md §3 for the class and the roll-up loop.
Why not the alternative: A nested map needs a guard at every level and a two-level iteration to read back. A concatenated string key (ownerId + '|' + stage) works but is case-sensitive, un-typed, and silently collides when a component value contains the separator.
Grouping Without a Second Query — AggregateResult vs Map
When to use: You need counts or sums per parent.
How it works: GROUP BY returns AggregateResult objects; an aggregated field without an alias gets an implied alias of the form expr0 (apexdev L9553–L9555). Read with ar.get('expr0') or alias the field. Note the caveat: "All aggregate functions other than COUNT() or COUNT(fieldname) include each row used by the aggregation as a query row" (apexdev L9558–L9560).
Why not the alternative: Grouping in Apex with Map<Id, List<SObject>> gives you the records as well as the count, which you need if the parent update depends on child field values. Use AggregateResult when only the aggregate matters; use the Map when you need the rows.
Decision Guidance
| Situation | Recommended Approach | Reason |
|---|---|---|
| Group child records by parent in a trigger | Map<Id, List<SObject>> built outside loops | Single pass; avoids SOQL in loop |
| Check if a key exists before reading its value | Map.containsKey(key) guard, or get(key)?.field | Map.get() returns null — not an exception (apexrefguide L222557) |
| Find the overlap between two Id Sets | Set.retainAll() on a copy | One platform call; avoids manual loop |
| Convert a query result to a lookup map | Map<Id, SObject> m = new Map<Id, SObject>(queryResult) | The only list-populating Map constructor is Map<ID,sObject>(recordList) (apexrefguide L222322) |
| Build a map from records that may be unsaved | Explicit loop with an Id != null check | Null keys collapse; the last unsaved record wins (apexdev L1694–L1696) |
| Accumulate state across Batch execute() chunks | Write results to SObject records at end of each chunk; avoid growing instance-level Maps | Instance-level collections in Database.Stateful grow against a ceiling that does not reset |
| De-duplicate a List of Ids | new Set<Id>(myList) | Set construction removes duplicates in one step |
| De-duplicate Ids arriving as Strings | Convert to Id first, then new Set<Id>(...) | Id normalizes 15 to 18 characters (apexdev L1255); String does not |
| Sort a List of custom objects | Pass a Comparator to List.sort(), or implement Comparable | Platform-native sort; avoids hand-rolled comparison logic (apexdev L1537–L1539) |
| Remove entries while walking a Map | Collect keys in a temporary List, remove after the loop | keySet() is backed by the map (apexrefguide L222249) |
| Key a roll-up on two dimensions | Custom key class with equals + hashCode | One guard-free map instead of a nested one (apexdev L7044–L7045) |
Recommended Workflow
- Answer the seven rows in Questions to Ask Before Configuring; the container choice and the key type fall out of rows 1 and 2, and rows 6 and 7 set the bulk-test volume.
- Read
references/code-examples.md§0 and pick the container from the access-pattern table before writing any declaration. If a canonical scaffold already exists —templates/apex/TriggerHandler.cls,templates/apex/BaseSelector.cls— call it rather than restating it. - Copy
CollectionUtils, and where the case needs themOpportunitySortComparatorandStageOwnerKey, fromreferences/code-examples.md§1–§3. Rename to the domain; do not inline the grouping logic into the handler. - Cross-check the design against
references/gotchas.md— in particular the keySet view, the null-Id collapse, theSet<String>Id trap, and the mutable custom key — and againstreferences/llm-anti-patterns.mdif the code was AI-generated. - Run
python3 scripts/check_apex_collections_patterns.py --manifest-dir <apex source root> --strict. Every ERROR is a defect; WARN and ADVISORY findings need a written reason to keep. - Adapt
CollectionUtilsTestfromreferences/code-examples.md§4: assert grouping counts, sort order across all keys including the null case, key-equality collapse, and heap headroom at 200 records insideTest.startTest()/Test.stopTest(). - Deploy and verify with §6–§7 —
sf project deploy start --manifest,sf apex run test --tests <YourTest>, then the Tooling API query that confirms the classes are Active.
Review Checklist
- [ ] All
Map.get()calls are preceded byMap.containsKey(), null-checked, or reached through?.. - [ ] No
Database.Statefulbatch class accumulates unbounded Map or List values acrossexecute()chunks. - [ ]
Set.retainAll()andSet.removeAll()are called on copies, not the original collection, when the original is needed afterward. - [ ] No SOQL query or DML statement appears inside a for loop.
- [ ]
Map<Id, SObject>construction fromList<SObject>uses the Map constructor (new Map<Id, SObject>(list)) rather than a manual loop where possible — and only when every record is persisted. - [ ] Inner Lists in
Map<Id, List<SObject>>are initialized with acontainsKeyguard (not overwriting an existing list). - [ ] No collection is added to, removed from, or cleared inside a loop that is iterating it, and no
keySet()is iterated while its map is edited. - [ ] Every user-defined class used as a Map key or Set element defines both
equals(Object)andhashCode(), and its key fields arefinal. - [ ] Record Ids are held as
Id, notString, wherever they are used as keys or set elements. - [ ] Any
List.sort()whose intended order is not the platform default is passed aComparatorthat handles null inputs.
Salesforce-Specific Gotchas
Full write-ups, each with What happens / When it occurs / How to avoid, are in references/gotchas.md. The short list:
Map.get()returns null for an absent key, so theNullPointerExceptionsurfaces at the field access, not the lookup.retainAll()andremoveAll()mutate the receiver, silently, with no exception.- A map key may be null and a repeated key overwrites the earlier entry, so unsaved records are never safe to key on — see gotcha 3 for what the guide does and does not document.
Set<SObject>and sObject map keys compare field values, not identity — and mutating the record breaks the mapping.- Instance collections in a
Database.Statefulbatch grow against a heap ceiling that does not reset between chunks. keySet()is a live view of the map, not a snapshot.Set<String>holding record Ids treats the 15- and 18-character forms as two distinct elements.List.sort()on sObjects uses a documented default field sequence that is almost never the business order.- Modifying any collection while iterating it is documented as unsupported, including through a
keySet()view. Decimalkeys that are numerically equal but differ in scale generally hash differently.
Output Artifacts
| Artifact | Description |
|---|---|
| Collection pattern review | Findings on Map null-guard coverage, Set mutation safety, key-type correctness, heap accumulation risk, and DML/SOQL loop violations |
CollectionUtils + Comparator + custom key class | Deployable classes from references/code-examples.md §1–§3, with -meta.xml, package.xml, and deploy order |
| Semantics test class | CollectionUtilsTest — asserts grouping, sort stability, key equality, Id-vs-String behaviour, and 200-record heap headroom |
| Checker report | Output of scripts/check_apex_collections_patterns.py, ERROR / WARN / ADVISORY |
| Batch heap remediation plan | Identifies unbounded instance-level collections in Database.Stateful classes and recommends the flush-per-chunk pattern |
Reference Files
| File | Read it when |
|---|---|
references/code-examples.md | You are about to write the class — container choice table, CollectionUtils, the multi-key Comparator, the custom key class, the test class, package.xml, deploy and verify |
references/gotchas.md | A collection is producing wrong data rather than an exception, or you are reviewing someone else's collection code |
references/examples.md | You want the worked trigger and service scenarios end to end, including the Stateful batch anti-pattern |
references/llm-anti-patterns.md | The code under review was AI-generated, or you are prompting an assistant to write collection logic |
references/well-architected.md | You need the pillar framing, the architectural trade-offs, or the official sources behind a claim |
Related Skills
apex/trigger-framework— use when the handler structure around the collection patterns is the primary concern.apex/batch-apex-patterns— use when the broader batch design (scope, start/execute/finish, error handling) is the focus.apex/apex-cpu-and-heap-optimization— use when the algorithm shape is already right and the transaction still exceeds CPU or heap.apex/apex-wrapper-class-patterns— use when the thing being collected is a wrapper or DTO and its shape is the question.apex/apex-design-patterns— use when the question is which layer the grouping logic belongs in.apex/governor-limits— use when heap or CPU limits are being hit and broader limit strategy is needed.apex/soql-fundamentals— use when the underlying SOQL driving collection population needs optimization.apex/exception-handling— use when NullPointerException from unguarded Map.get() is part of a broader error handling review.