Lists, dicts, sets, tuples
Python ships four core containers — list, dict, set, tuple. Picking the right one is a question of mutability and hashability, and the wrong pick costs O(n) where O(1) was free.
A reviewer flags your function: it scans a 50,000-entry list with if user_id in seen on every request, and the endpoint crawls. You wrote it like JS — an array, a loop, .includes() in spirit. The fix is one character of intent: make seen a set. Membership drops from O(n) to O(1) and the p99 collapses. Same data, same code shape — a different container, because in Python the container is the performance decision. Reaching for a list by reflex, the way you’d reach for [] in JavaScript, is the most common way to write accidentally quadratic Python.
Two questions decide everything: mutable? hashable?
Python gives you four built-in containers, and choosing between them is not taste — it is two yes/no questions. Is it mutable? — can you change it in place after creation. Is it hashable? — can Python compute a stable hash so the object can be a dict key or a set member. These two properties are linked: an object is hashable only if its value can’t change underneath the hash, so mutable built-ins are not hashable. That single rule explains why a list cannot be a dict key but a tuple can.
list— ordered, mutable, the workhorse sequence. Closest to a JSArray, but the methods differ (.append(), not.push();.sort()mutates in place and returnsNone). Append is amortized O(1);insert(0, x),del lst[0], andx in lstare all O(n). Not hashable.dict— a hash map with guaranteed insertion order since Python 3.7. Keys must be hashable; lookup, insert, and delete are O(1) average. It is JS’sObjectandMapunified into one type: string-keyed and arbitrary-hashable-keyed, ordered, fast.set— an unordered collection of unique, hashable members. O(1) membership, plus first-class|union,&intersection,-difference. Like JSSet, but with real set algebra built in.tuple— ordered, immutable, and hashable if every element is hashable. JavaScript has no equivalent. It is the type for fixed records, multiple return values, and — crucially — compositedict/setkeys.
Together these four types cover every data-shaping task, but they are not interchangeable. When you pick the wrong one — a list for membership or a tuple where a dict belongs — the cost shows up as real algorithmic complexity, not a style warning.
point = (3, 4) # tuple: fixed record, hashable
grid = {point: "origin"} # tuple as a dict key — works
grid[[3, 4]] # TypeError: unhashable type: 'list'
x, y = point # unpacking a tuple into two names
def bounds(): return 0, 100 # returning a tuple (the comma makes it)
lo, hi = bounds()The cheat sheet, and the JS translation
When you come from JavaScript, the mistake isn’t syntax — it’s mapping Array onto everything. JS makes you fake a set with an object ({}) or a Map, fake set algebra with filters, and has no immutable record at all. Python splits those jobs across four purpose-built types, and the cost of using the wrong one is real algorithmic complexity, not just style.
| Type | Mutable? | Ordered? | Hashable? | Reach for it when… | JS analog |
|---|---|---|---|---|---|
list | yes | yes | no | an ordered, growable sequence you iterate or index | Array |
dict | yes | yes (insertion) | no | key→value lookup, counting, grouping, any mapping | Object / Map |
set | yes | no | no | membership tests, dedup, union/intersection | Set |
tuple | no | yes | yes* | fixed records, multi-return, composite dict/set keys | (none) |
The asterisk on tuple matters: a tuple is hashable only if all its elements are. (1, 2) is a fine key; (1, [2]) is not, because it nests a list. Hashability is recursive.
You process an event stream and must answer 'have I already seen this (user_id, day) pair?' millions of times. Pick the structure that holds the seen pairs.
Identity vs equality, and the mutable-default trap
Two pitfalls bite engineers crossing from JS. First, identity (is) vs equality (==). == compares values; is compares object identity (same object in memory) and is what None/True/False checks should use. They diverge in ways that look like magic: [1,2] == [1,2] is True but [1,2] is [1,2] is False (two distinct lists). CPython caches small integers and short strings, so 256 is 256 may be True while 257 is 257 is False — an implementation detail you must never rely on. Rule: use == for value checks, reserve is for None and other singletons.
Second, the mutable default argument. A default value is evaluated once, at function definition, and shared across every call — so a mutable default like [] or {} accumulates state between calls, a bug that looks like haunting.
def add(item, bucket=[]): # bucket is created ONCE, shared forever
bucket.append(item)
return bucket
add("a") # ['a']
add("b") # ['a', 'b'] ← surprise: the same list persisted
def add(item, bucket=None): # the fix: sentinel + fresh object per call
if bucket is None:
bucket = []
bucket.append(item)
return bucket▸Why this works
Assignment in Python binds a name to an object; it never copies. So b = a for a list means a and b are the same list, and b.append(1) mutates both. Copying is explicit: a.copy() (or a[:], list(a)) makes a shallow copy — a new outer container holding the same inner objects, so mutating a nested list still shows through both. For a fully independent clone of nested data, reach for copy.deepcopy(). This is the same reference-semantics model as JS objects and arrays — the trap is assuming = duplicates.
Why does `d = {[1, 2]: 'x'}` raise TypeError, while `d = {(1, 2): 'x'}` works?
Membership testing `if x in c` runs millions of times in a hot loop. Which container keeps it O(1)?
A function should record each unique (user, action) pair once and answer membership fast. Order the decision from the deciding question down to the final pick:
- 1 Do I need fast membership / uniqueness? → yes, so a hash container (set/dict), not a list
- 2 Is it pure membership (seen-or-not) or do I store a value? → seen-or-not, so set over dict
- 3 What is each member? → a compound (user, action) identity, so a composite key
- 4 Is that composite hashable? → use a tuple (immutable, hashable), not a list
- 5 Result: set of (user, action) tuples — O(1) membership, automatic dedup
- 01Why can a tuple be a dict key but a list cannot, and what links that to mutability?
- 02What is the mutable-default-argument trap, and what's the correct fix?
Python’s four core containers split across two questions: mutability and hashability. A list is your ordered, mutable, growable sequence — the JS Array, but in is an O(n) scan and .append() not .push(). A dict is an insertion-ordered hash map with O(1) lookup, unifying JS’s Object and Map. A set holds unique hashable members with O(1) membership and real union/intersection algebra. A tuple is immutable and ordered, hashable when its elements are — the type for fixed records, multiple returns, and composite dict/set keys, with no JS equivalent. Mutability gates hashability: a list can’t be a key, a tuple can. The recurring senior mistakes are writing accidentally quadratic code by testing membership against a list instead of a set, leaning on is where == is meant (CPython’s small-int and string caching makes is lie), and the mutable-default-argument trap — fixed with a None sentinel and a fresh object per call. Now when you see a list in a hot membership check or a list used as a dict key, you’ll know which question it fails — and which container to reach for instead.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.