Use a List when order matters or duplicates are legitimate, a Set when membership is the only question you ask, and a Map when you look things up by a key. The decision that actually costs you money is calling contains() on a List inside a loop. That turns a linear scan into a quadratic one, and it surfaces as CPU time rather than as a clean limit exception, so nobody knows where it came from.
What each one is for
- List keeps insertion order, allows duplicates, supports index access, and is the only one of the three you can pass straight to DML.
- Set holds unique values, has no index, and answers
contains()by hash rather than by scanning. - Map holds key to value pairs with unique keys, and is how you avoid a query inside a loop.
Most Apex needs all three in the same method. Gather ids into a Set, query into a Map, build a List for the DML.
The cost of contains() on a List
List.contains() walks the list from the start until it finds a match. On a list of 5,000 processed ids, checking each of 5,000 incoming records means up to 25 million comparisons. Your synchronous CPU budget is 10,000 ms, and this is a very effective way to spend it.
// Slow. Scans the list on every iteration.
List<Id> seen = new List<Id>();
for (Contact c : records) {
if (!seen.contains(c.AccountId)) {
seen.add(c.AccountId);
}
}
// Fast. Hash lookup, and the uniqueness is free.
Set<Id> seen = new Set<Id>();
for (Contact c : records) {
seen.add(c.AccountId);
}
The second version is shorter as well as faster, because a Set already refuses duplicates. Whenever you find yourself writing a guard before an add, you have picked the wrong collection.
indexOf() has the same problem and the same fix. If you genuinely need the position of an element, keep a Map<Object, Integer> alongside the list.
Ordering guarantees, and the lack of them
A List preserves the order you put things in, and sort() will reorder it predictably. That is the whole guarantee list.
Salesforce does not guarantee the iteration order of a Set, or of a Map’s keySet() and values(). In practice you will often observe insertion order, which is exactly why this bites people: the code works for two years and then a platform change reorders something and a report comes out wrong. If order matters, convert to a List and sort it.
List<String> keys = new List<String>(codeToLabel.keySet());
keys.sort();
for (String k : keys) {
// deterministic, every time
}
One exception is worth knowing: new Map<Id, Account>([SELECT ...]) gives you a map whose values() follow the query’s ORDER BY. That is convenient. It is still not something to build a sorted report on.
Key equality is where the surprises live
Three behaviours catch people out.
Id keys normalise case and length. A Set<Id> or Map<Id, X> treats the 15 and 18 character forms of the same record as the same key. A Set<String> holding the same values does not, and that mismatch is a classic integration bug.
String keys are case sensitive. SOQL comparison usually is not. So a map built from query results keyed on an email address will miss a lookup done with different capitalisation. Normalise with toLowerCase() when you build the map, and again when you read it.
SObject keys compare every field. Two Account instances are equal as set members only if all their populated fields match. Key on the Id instead, always.
Memory, briefly
Heap is 6 MB synchronous and 12 MB asynchronous. A Map of full sObject records is the usual way to exhaust it. Query only the fields you need, and if you are caching for a lookup, cache the one field you actually read rather than the whole record. A Map<Id, String> costs a fraction of a Map<Id, Account>.
Verified in a Summer ’26 scratch org on 14 September 2026. Inserting z, a, m in that order and printing the collection gives {a, m, z} for both a Map<String,…> keySet and a Set<String>. String keys come back sorted, not in insertion order. Do not rely on either behaviour, and never on insertion order.
Frequently asked
Is a Set faster than a Map for membership? They perform the same. Pick the Set when you have nothing to store against the key, because the code reads better.
Can I sort a Set? No. Copy it into a List first. new List<String>(mySet) does that in one line.
Does removing from a List inside a for loop work? Not reliably, and it is the source of skipped elements. Build a second List of the survivors instead.
Do collection operations count against a governor limit? Only CPU and heap. There is no limit on the number of map lookups, which is precisely why trading queries for lookups is the standard move.
