Skip to content
Back to skills

Apex Collections Patterns

ASecurity

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. Trigger keywords: 'Map<Id, SObject>', 'containsKey', 'retainAll', 'putAll', 'Set intersection', 'heap limit', 'collection in loop', 'unbounded accumulation', 'group by parent Id', 'Comparator', 'Comparable', 'equals and hashCode', 'keySet', 'Set<String> duplicates', '15 vs 18 ch...

  • 15 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 6, 2026
ai-agentspythongoexpressspringtestingdebuggingapidatabaseperformance

Works with

  • api

Security analysis

A100/100

Pro scans all 7 files and shows the line behind each finding

Scanned October 4, 2026

npx -y skills add PranavNagrecha/AwesomeSalesforceSkills --skill apex-collections-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Apex Collections Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Apex Collections Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/pranavnagrecha-apex-collections-patterns/badge)](https://www.skillsdirectory.com/skills/pranavnagrecha-apex-collections-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: apex-collections-patterns
description: "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. Trigger keywords: 'Map<Id, SObject>', 'containsKey', 'retainAll', 'putAll', 'Set intersection', 'heap limit', 'collection in loop', 'unbounded accumulation', 'group by parent Id', 'Comparator', 'Comparable', 'equals and hashCode', 'keySet', 'Set<String> duplicates', '15 vs 18 character Id', 'modify collection while iterating', 'List index out of bounds'. NOT for SOQL query optimization — use apex/soql-fundamentals. NOT for async job design — use apex/apex-queueable-patterns or apex/batch-apex-patterns. NOT for Platform Cache strategies — use apex/platform-cache. NOT for wrapper/DTO class design — use apex/apex-wrapper-class-patterns. NOT for CPU-time tuning — use apex/apex-cpu-and-heap-optimization."
category: apex
salesforce-version: "Spring '25+"
well-architected-pillars:
  - Performance
  - Reliability
triggers:
  - "Map containsKey guard null pointer exception apex collections"
  - "retainAll mutates set apex bulkification pattern"
  - "unbounded list accumulation Database.Stateful batch heap limit"
  - "putAll list SObject null Id NullPointerException apex"
  - "nested loop performance Map lookup bulkified trigger"
  - "group child records by parent Id in Apex"
  - "sort a list of sObjects by two fields in Apex"
  - "use a custom class as a Map key in Apex"
  - "why does my Set<String> contain the same record twice"
  - "remove entries from a map while iterating over it"
  - "replace a nested loop over two lists with a Map lookup"
  - "index a query result by Id without writing a loop"
  - "build a Map of String to List of records in Apex"
  - "list index out of bounds exception in an Apex trigger"
tags:
  - apex-collections
  - bulkification
  - maps-and-sets
  - heap-management
  - triggers
  - batch-apex
  - comparator
  - equals-and-hashcode
inputs:
  - "Apex class or trigger body using List, Set, or Map"
  - "Whether the context is a trigger, batch, or service layer"
  - "Known governor limit pressure (heap, CPU, SOQL rows)"
  - "The access pattern the collection has to serve — ordered, membership, or keyed lookup"
  - "Whether keys are record Ids, Strings derived from Ids, or a custom class"
outputs:
  - "Refactored collection usage with bulkified patterns"
  - "Heap and null-safety review findings"
  - "Decision guidance on Map vs Set vs List for the given scenario"
  - "A deployable CollectionUtils class, Comparator, and custom key class with tests"
  - "Checker findings from scripts/check_apex_collections_patterns.py"
dependencies: []
version: 1.1.0
author: Pranav Nagrecha
updated: 2026-09-05
---

# 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()` and `removeAll()` 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:**
1. Query all relevant parent records using the set of parent Ids extracted from `Trigger.new`.
2. Build a `Map<Id, List<Child__c>>` by iterating the query results once, using `Map.containsKey()` guard before `Map.get()`.
3. Iterate `Trigger.new`, look up each record's parent group from the Map, and accumulate changes.
4. 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:**
1. Construct the first Set from the incoming Ids: `Set<Id> incoming = new Set<Id>(triggerIds);`
2. Construct or load the exclusion Set from a SOQL or Custom Metadata query.
3. Create a working copy if the original Set must be preserved: `Set<Id> overlap = new Set<Id>(incoming);`
4. Call `overlap.retainAll(exclusionIds);` — the result is the intersection in one platform operation.
5. 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

1. 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.
2. 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.
3. Copy `CollectionUtils`, and where the case needs them `OpportunitySortComparator` and `StageOwnerKey`, from `references/code-examples.md` §1–§3. Rename to the domain; do not inline the grouping logic into the handler.
4. Cross-check the design against `references/gotchas.md` — in particular the keySet view, the null-Id collapse, the `Set<String>` Id trap, and the mutable custom key — and against `references/llm-anti-patterns.md` if the code was AI-generated.
5. 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.
6. Adapt `CollectionUtilsTest` from `references/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 inside `Test.startTest()`/`Test.stopTest()`.
7. 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 by `Map.containsKey()`, null-checked, or reached through `?.`.
- [ ] No `Database.Stateful` batch class accumulates unbounded Map or List values across `execute()` chunks.
- [ ] `Set.retainAll()` and `Set.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 from `List<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 a `containsKey` guard (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)` and `hashCode()`, and its key fields are `final`.
- [ ] Record Ids are held as `Id`, not `String`, wherever they are used as keys or set elements.
- [ ] Any `List.sort()` whose intended order is not the platform default is passed a `Comparator` that 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:

1. `Map.get()` returns null for an absent key, so the `NullPointerException` surfaces at the field access, not the lookup.
2. `retainAll()` and `removeAll()` mutate the receiver, silently, with no exception.
3. 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.
4. `Set<SObject>` and sObject map keys compare field values, not identity — and mutating the record breaks the mapping.
5. Instance collections in a `Database.Stateful` batch grow against a heap ceiling that does not reset between chunks.
6. `keySet()` is a live view of the map, not a snapshot.
7. `Set<String>` holding record Ids treats the 15- and 18-character forms as two distinct elements.
8. `List.sort()` on sObjects uses a documented default field sequence that is almost never the business order.
9. Modifying any collection while iterating it is documented as unsupported, including through a `keySet()` view.
10. `Decimal` keys 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.

Files in this skill

  • SKILL.md13 KB
  • references/examples.md5.7 KB
  • references/gotchas.md3.8 KB
  • references/llm-anti-patterns.md4.8 KB
  • references/well-architected.md3.4 KB
  • scripts/check_apex_collections_patterns.py1.9 KB
  • templates/apex-collections-patterns-template.md637 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…