S008 · Data Structures — list, tuple, dict, set (when to use what)
The one-page decision matrix that separates senior Python devs from the rest: given a data shape and a query pattern, know within 5 seconds which built-in structure to reach for — and its Big-O cost.
🎯 Pick the right built-in data structure for any read/write pattern, and know its Big-O cost, without pausing to think.
Why this session exists
Almost every ‘why is my Python slow?’ complaint traces back to the same root cause: using a list when the code needs a set or dict. Membership tests on a 100k-element list are O(n); on a set they're O(1). Multiply by a hot loop and your service goes from 40 ms to 40 seconds. The remedy is not deeper knowledge of algorithms — it's a five-item decision matrix you can invoke on reflex. This session installs that matrix, plus the six collections helpers (Counter, defaultdict, deque, OrderedDict, namedtuple, ChainMap) that every senior Python dev uses weekly.
- Choose between list, tuple, dict, set, and frozenset for any scenario in 5 seconds — with Big-O justification.
- Reach for the right `collections` helper: Counter for tallying, defaultdict for grouping, deque for FIFO/LIFO, namedtuple for tiny records.
- Diagnose the ‘quadratic list search’ perf bug on sight and know the one-line fix.
- Explain why `dict` is O(1) but sometimes isn't (hash collisions, custom __hash__).
- Reason about memory: a dict of 1M small items vs a list of 1M small items — order of magnitude estimate.
Prerequisites
- S005 — Python Variables & Types — mutability rules matter here.
- S006 — Control Flow — comprehensions all over.
- S007 — Functions — you'll pass structures around.
(a) Intuition · 5 min
Every tradesperson has a hammer, a screwdriver, pliers, a wrench. A senior tradesperson picks the right one in 2 seconds. A junior tries to use a hammer on everything and blames the wall when it doesn't work.
Python's built-in data structures are the same. Each is optimised for one job. Using a list as a set (‘I'll just check `x in the_list`’) works — until the list has 100,000 items and every check becomes a full linear scan.
Concretely: list is an ordered array. tuple is an immutable list. dict is a hash table (key → value). set is a hash table without values. Each supports fast operations in one dimension and slow operations in the other.
Big-O tells you which. If your code hits an operation in a hot loop, and that operation is O(n) for your structure but O(1) for another — switch. The change is usually one line.
The five-item decision matrix
- Ordered, mutable, index-access — LIST. Default when you need a resizeable sequence.
- Ordered, IMMUTABLE, small — TUPLE. Fixed records, dict keys, function return groups.
- Key → value lookup — DICT. When you'll ask ‘what value is stored under this key?’ millions of times.
- Unique items, ‘is X in this collection?’ — SET. Membership tests, dedup, set algebra (union, intersect, diff).
- Immutable set (needs to be a dict key or set element) — FROZENSET. Rare but critical when it matters.
A quick history
- 1991Python 0.9.0 — list + dict + tupleAll three built-in from day one. dict is a hash table; list is a dynamic array; tuple is an immutable list.
- 1994Python 1.0 — Dictionary iteration order was undefinedBeginners kept relying on it. Python didn't guarantee anything until 2016.
- 2003Python 2.3 — set built-inSets get promoted from `Set` module to a first-class type. `{1, 2, 3}` literal comes later (2.7).
- 2004collections moduleCounter, deque, defaultdict, OrderedDict, namedtuple — the ‘standard library gems’.
- 2017Python 3.7 — dicts are officially insertion-orderedCPython 3.6 shipped it as an ‘implementation detail’; 3.7 makes it a language guarantee. OrderedDict is now mostly redundant.
(b) Visual walkthrough · 15 min
The Big-O cheat sheet you'll refer to for years
Stare at it. When your code is slow, ask ‘which operation is in my hot loop, and is it O(n) here but O(1) elsewhere?’ That's usually the fix.
The four built-ins side by side
Ordered, mutable, indexed
- [1,2,3]
- append/pop (end): O(1)
- in / insert middle: O(n)
- Use for: sequence of items, order matters
Ordered, IMMUTABLE, indexed
- (1,2,3)
- hashable (if elements are)
- Use for: fixed-length records, dict keys, function returns
- Slightly less memory than list
key → value, hashed
- {'a':1,'b':2}
- get/set/in: O(1) avg
- Ordered by insertion (3.7+)
- Use for: lookup by name, tallies, indexes
Unique, unordered, hashed
- {1,2,3}
- add/in: O(1) avg
- No duplicates
- Use for: dedup, membership tests, set algebra
The six collections helpers you should reach for on reflex
Every Python dev's daily toolkit
The decision tree
If yes → list, tuple, deque, or dict (dicts preserve insertion order since 3.7).
If yes → set or frozenset. If no → list or tuple.
If yes → dict. Consider defaultdict or Counter for common patterns.
If yes → list/dict/set. If no (or you need to hash it) → tuple/frozenset.
If yes → deque (O(1)). A list's popleft is O(n).
The mental model to hold
"A dict lookup is O(1), so dicts are always the fast choice. And x in my_list versus x in my_set — both are just membership tests, the difference can't matter much."
Hash lookup is O(1) amortised and average-case, over an assumed-good hash function. x in list is O(n) — a linear scan comparing every element. On 10 elements the difference is noise; on 10 million it is the difference between milliseconds and minutes, and it turns an O(n) algorithm into O(n²) without changing a line of your logic.
Because the syntax is identical. in works on both, returns the same answer, and gives no hint that one walks the entire container. Python deliberately hides the cost behind a uniform interface, which is excellent for readability and terrible for cost intuition. The trap is specifically the nested form: a loop over n records that does if record.id in known_ids against a list is quadratic, and it will pass every test you write on 100 rows before falling over on the real dataset. This is the single most common accidental-quadratic in data code.
Same code, one word changed:
import timeit
n = 100_000
lst = list(range(n)); st = set(lst)
# worst case: the item is absent, so list must scan all n
print(timeit.timeit(lambda: (n+1) in lst, number=100))
print(timeit.timeit(lambda: (n+1) in st, number=100))
# the set version is not 'a bit' faster - it is a different complexity classWhy does list.append count as O(1) when the underlying array must sometimes be reallocated and every element copied? A single append can genuinely cost O(n) — so where does the O(1) come from?
- 1A Python list is a contiguous array of pointers, which is what makes
lst[i]a single address computation.forced by · random access in constant time requires elements at predictable addresses - 2Contiguity means capacity is fixed at allocation. Growing past it requires allocating a new block and copying every existing pointer — an O(n) operation.forced by · you cannot extend a contiguous block in place; the memory after it belongs to someone else
- 3If the new block were only one slot bigger, every single append would copy everything: n appends would cost 1+2+...+n = O(n²).forced by · a constant increment means a resize on every append, each costing O(current size)
- 4Instead the list over-allocates proportionally — new capacity grows by a factor of the current size, so each resize buys a number of free appends proportional to the work it just did.forced by · only geometric growth makes the free appends grow as fast as the copy cost
- 5Total copying across n appends becomes a geometric series n + n/2 + n/4 + ... < 2n, i.e. O(n) total, or O(1) per append averaged over the sequence.forced by · a geometric series with ratio below 1 sums to a constant multiple of its largest term
Therefore "O(1) amortised" is a precise claim about a sequence of operations, not about any single one. Any individual append may still stall for an O(n) copy.
And note what this predicts, and this is the part that matters in production: amortised bounds say nothing about tail latency. In a latency-sensitive service the occasional resize is a visible spike, which is exactly why you preallocate when the size is known. It also predicts why list.insert(0, x) stays O(n) forever — no growth strategy helps when every element must shift — and therefore why collections.deque exists.
There are really only two data-structure ideas underneath everything Python gives you. Contiguous array: elements sit side by side, so you can jump to index i instantly and the CPU cache prefetches your next elements for free — but inserting in the middle shifts everything. Hash table: a function maps a key to a bucket, so you find any key instantly regardless of size — but you need a stable hash, you spend memory on empty slots, and you lose locality.
list and tuple are the first. dict and set are the second. Everything else — Counter, defaultdict, deque, heapq — is one of those two with a policy bolted on.
- Need position or order → array (list). Need identity lookup or dedup → hash (set/dict). Need both ends fast → deque. Need "smallest so far" repeatedly → heap.
- Membership tests inside a loop must be against a set or dict. Converting a list to a set costs O(n) once and repays itself after the second lookup.
- Hashability requires immutability, which is why tuples can be dict keys and lists cannot — and why a tuple containing a list also cannot.
- Since 3.7 dicts preserve insertion order as a language guarantee, but they are still hash tables: order is a bonus, not the access mechanism.
Fire this model the moment you see: a nested loop doing membership tests · if x not in results against a list · code that sorts inside a loop · TypeError: unhashable type · a job that ran in seconds on sample data and hours on the full set.
You must deduplicate and join two large collections of records in memory. Sort both and merge, or hash one and probe it?
Hash unless memory says otherwise, and know which side you are building from — always the smaller one. The senior instinct is to estimate before choosing: rows × bytes-per-row against available RAM, remembering that Python objects carry substantial per-object overhead, so a "small" list of a few million dicts is not small.
And the meta-answer: if you are hand-writing a join in Python over data that came from a database, the real tradeoff you should be evaluating is whether this belongs in Python at all.
(c) Hands-on · 25 min
Save as s008_data_structures.py, run.
"""s008_data_structures.py — the built-in menu, in action."""
from __future__ import annotations
from collections import Counter, defaultdict, deque, namedtuple, ChainMap
import time
# =========================
# Part 1 · O(n) list search vs O(1) set search
# =========================
print("--- Part 1 · perf ---")
N = 100_000
big_list = list(range(N))
big_set = set(big_list)
probe = N - 1 # worst case for the list
t = time.perf_counter()
for _ in range(1000):
_ = probe in big_list
list_ms = (time.perf_counter() - t) * 1000
t = time.perf_counter()
for _ in range(1000):
_ = probe in big_set
set_ms = (time.perf_counter() - t) * 1000
print(f"list 1000 checks: {list_ms:.1f} ms")
print(f"set 1000 checks: {set_ms:.3f} ms")
print(f"set is ~{list_ms/set_ms:.0f}× faster for membership")
# =========================
# Part 2 · Counter — tally in one line
# =========================
print("\n--- Part 2 · Counter ---")
sentence = "the quick brown fox jumps over the lazy dog the fox is quick"
word_counts = Counter(sentence.split())
print(word_counts.most_common(3))
# Multiset math for free
bag_a = Counter("aabbc")
bag_b = Counter("abcd")
print("intersect:", bag_a & bag_b) # min of each
print("sum: ", bag_a + bag_b) # add counts
print("diff: ", bag_a - bag_b) # subtract (min 0)
# =========================
# Part 3 · defaultdict — grouping without KeyError
# =========================
print("\n--- Part 3 · defaultdict ---")
rows = [("engineer", "ada"), ("engineer", "linus"), ("cook", "julia"), ("engineer", "grace")]
# BAD — verbose
groups_bad: dict[str, list[str]] = {}
for role, name in rows:
if role not in groups_bad:
groups_bad[role] = []
groups_bad[role].append(name)
# GOOD — defaultdict does the ‘if missing’ for you
groups: dict[str, list[str]] = defaultdict(list)
for role, name in rows:
groups[role].append(name)
print(dict(groups))
# =========================
# Part 4 · deque — O(1) at both ends
# =========================
print("\n--- Part 4 · deque ---")
# BFS queue — a canonical use
def bfs_levels(root: dict, children_key: str = "children") -> list[list]:
queue = deque([(root, 0)])
levels: dict[int, list] = defaultdict(list)
while queue:
node, level = queue.popleft() # O(1) — list.pop(0) is O(n)
levels[level].append(node["name"])
for child in node.get(children_key, []):
queue.append((child, level + 1))
return [levels[i] for i in sorted(levels)]
tree = {
"name": "root",
"children": [
{"name": "a", "children": [{"name": "a1"}, {"name": "a2"}]},
{"name": "b", "children": [{"name": "b1"}]},
],
}
print(bfs_levels(tree))
# Ring buffer — the last N events, in constant memory
recent = deque(maxlen=5)
for i in range(20):
recent.append(i)
print("last 5:", list(recent))
# =========================
# Part 5 · namedtuple — tiny records
# =========================
print("\n--- Part 5 · namedtuple ---")
Point = namedtuple("Point", "x y")
p = Point(3, 4)
print(p, p.x, p.y)
print("as dict:", p._asdict())
print("with replacement:", p._replace(y=99))
# Note: namedtuples are hashable and can be dict keys or set elements.
seen: set[Point] = {Point(0, 0), Point(1, 1), Point(0, 0)}
print("unique points:", seen)
# For anything larger than 3 fields, prefer @dataclass (S014).
# =========================
# Part 6 · ChainMap — layered config
# =========================
print("\n--- Part 6 · ChainMap ---")
cli = {"log_level": "DEBUG"}
env = {"log_level": "INFO", "port": 8080}
defaults = {"log_level": "WARNING", "port": 80, "workers": 4}
config = ChainMap(cli, env, defaults) # left-most wins
print("log_level:", config["log_level"]) # DEBUG (from cli)
print("port :", config["port"]) # 8080 (env, since cli lacks it)
print("workers :", config["workers"]) # 4 (defaults)
# =========================
# Part 7 · Sets and set algebra
# =========================
print("\n--- Part 7 · set algebra ---")
writers = {"ada", "linus", "grace"}
speakers = {"grace", "hopper", "guido"}
print("both: ", writers & speakers)
print("either: ", writers | speakers)
print("writers only:", writers - speakers)
print("xor: ", writers ^ speakers)
# Dedup while preserving order (3.7+ dicts are ordered)
def dedup(items):
return list(dict.fromkeys(items))
print("dedup:", dedup([3, 1, 4, 1, 5, 9, 2, 6, 5, 3]))
# =========================
# Part 8 · When you need immutability — frozenset
# =========================
print("\n--- Part 8 · frozenset ---")
flag_sets = {
frozenset({"admin", "read"}): "admin-role",
frozenset({"read"}): "reader-role",
}
print(flag_sets[frozenset({"read", "admin"})]) # order-independent lookup
# A regular set can't be a dict key: TypeError: unhashable type: 'set'What each block teaches
Anatomy of the exercises
Given a stream of ~1M strings, return the top 10 most frequent. Constraints:
- Solve with
Counter.most_common(10)— the natural Python one-liner. - Solve with
heapq.nlargest(10, counts.items(), key=lambda x: x[1])— the same in HOF style. - Time both on 1M random 4-letter strings. Which is faster and why?
Bonus: what if the stream is a generator you can only iterate once? (Answer: same code — Counter accepts any iterable and reads it once.)
(d) Production reality · 15 min
A dev added a ‘recent driver IDs’ cache: recent_drivers = [], appended on every ride, checked on every dispatch (if driver_id in recent_drivers). Under normal load, the list stayed small. On a busy Friday it hit 500k entries and every check went linear.
set() instead of list(); (2) bound it with deque(maxlen=10_000) (or a proper LRU / Redis). P99 back to 60 ms in a single deploy.A background job iterates a dict of session tokens and prunes expired ones inside the loop:
for tok, session in sessions.items():
if session.expired():
del sessions[tok] # RuntimeError under any Python 3.xIterate a snapshot: for tok, session in list(sessions.items()):. Alternatively, collect the keys first and delete in a second pass. Never mutate a dict/set/list while iterating it.
list(d.items()) or build a new collection with a comprehension.A dev builds a mapping label_to_examples: dict[str, list[np.ndarray]] where each ndarray is 1 MB. They expect a few hundred labels; the data has 500,000 (mislabeled long tail). RAM balloons past 500 GB.
The bug isn't the dict; it's assuming a bounded cardinality. Log the cardinality on ingestion, cap it with a defaultdict that rolls into ‘other’, or shift storage out of process (Parquet, DuckDB, Redis).
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- When do you use a set instead of a list? (one rule)
- What's
defaultdict(list)for? (one concrete example) - Why is
dequebetter thanlistfor a queue? (Big-O both ends)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.