Search Tech Journey

Find topics, journeys and posts

6-month learning plan8 / 130
back to blog
pythonbeginner 55m read

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.

🧠SoftwareM01 · Python Foundations· Session 008 of 130 90 min

🎯 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.

You will be able to
  • 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



(a) Intuition · 5 min

Data structures are tools with different edges
🌍 Real world

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.

💻 Code world

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

Match the shape of your data + query to a structure
  • 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

  1. 1991
    Python 0.9.0 — list + dict + tuple
    All three built-in from day one. dict is a hash table; list is a dynamic array; tuple is an immutable list.
  2. 1994
    Python 1.0 — Dictionary iteration order was undefined
    Beginners kept relying on it. Python didn't guarantee anything until 2016.
  3. 2003
    Python 2.3 — set built-in
    Sets get promoted from `Set` module to a first-class type. `{1, 2, 3}` literal comes later (2.7).
  4. 2004
    collections module
    Counter, deque, defaultdict, OrderedDict, namedtuple — the ‘standard library gems’.
  5. 2017
    Python 3.7 — dicts are officially insertion-ordered
    CPython 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

list

Ordered, mutable, indexed

  • [1,2,3]
  • append/pop (end): O(1)
  • in / insert middle: O(n)
  • Use for: sequence of items, order matters
tuple

Ordered, IMMUTABLE, indexed

  • (1,2,3)
  • hashable (if elements are)
  • Use for: fixed-length records, dict keys, function returns
  • Slightly less memory than list
dict

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
set

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

Counter
Tally occurrences of hashable items. `Counter('abracadabra')` → {a:5, b:2, r:2, c:1, d:1}. `.most_common(3)` gives top-3.
count
defaultdict
Dict that auto-creates missing values. `defaultdict(list)` lets `d[k].append(v)` never KeyError. Grouping is now one line.
group
deque
Double-ended queue with O(1) push/pop at both ends. `deque(maxlen=N)` gives a ring buffer for free.
queue
namedtuple
Tuple with named fields. `Point = namedtuple('Point', 'x y')`. Immutable, hashable, memory-cheap. Prefer @dataclass for anything larger.
record
OrderedDict
Was essential pre-3.7; now mostly redundant. Still useful for `move_to_end()` (LRU caches) and `popitem(last=False)` (FIFO).
legacy-ish
ChainMap
Layer multiple dicts as one view. `ChainMap(cli_args, env_vars, defaults)` — search order is left-to-right. Great for config precedence.
compose

The decision tree

1step 1
Do you need ORDER?

If yes → list, tuple, deque, or dict (dicts preserve insertion order since 3.7).

2step 2
Do you need UNIQUENESS?

If yes → set or frozenset. If no → list or tuple.

3step 3
Do you need KEY→VALUE?

If yes → dict. Consider defaultdict or Counter for common patterns.

4step 4
Do you MUTATE it?

If yes → list/dict/set. If no (or you need to hash it) → tuple/frozenset.

5step 5
Do you push/pop at BOTH ends?

If yes → deque (O(1)). A list's popleft is O(n).

The mental model to hold


Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 class
From first principles
Start with the question

Why 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?

  1. 1
    A 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
  2. 2
    Contiguity 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
  3. 3
    If 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)
  4. 4
    Instead 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
  5. 5
    Total 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

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.

Mental modelContiguity versus hashing

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.
🔔 Fires when you see

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.

The tradeoff

You must deduplicate and join two large collections of records in memory. Sort both and merge, or hash one and probe it?

Hash join (build a dict/set from one side)
+ you gain O(n + m) time; a single pass over each side; trivially expressed in Python and by far the fastest when the build side fits in memory
− you pay O(n) extra memory for the hash table with real per-entry overhead beyond the data itself; output order is arbitrary; degrades badly if the build side does not fit
pick when the smaller side fits comfortably in RAM with headroom — the default choice, and the reason query engines pick hash joins whenever they can
Sort-merge
+ you gain O(n log n) but with bounded memory once sorted, sequential access patterns that are cache- and disk-friendly, and sorted output you may need anyway
− you pay the log n factor, and you pay the sort even when the data was nearly ordered; more code to get the merge boundaries right
pick when the data exceeds memory (external sort spills to disk gracefully, a hash table does not), or one or both sides are already sorted, or downstream needs sorted output
Keep it in the database / engine
+ you gain the planner chooses between these strategies with statistics you don't have, uses indexes, and never materialises the intermediate in your process
− you pay a round trip, less control, and query cost you may be billed for
pick when the data already lives in a table and pulling it into Python is only to do work SQL could express — almost always true for joins and aggregations
What a senior engineer actually does

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

Part 1 · perf reality
1000 checks on a 100k-list vs set differ by 100-1000×. Run it — you feel it. This is the single most common perf bug in Python.
perf
Part 2 · Counter
One-line tally + free multiset algebra (+, -, &, |). The right tool for word counts, histograms, most-frequent-item.
count
Part 3 · defaultdict
Replaces the ubiquitous `if key not in d: d[key] = []` pattern. `defaultdict(int)`, `defaultdict(set)`, `defaultdict(list)` are the three you'll use most.
group
Part 4 · deque
O(1) at both ends. Use for BFS, ring buffers, LRU eviction, task queues. A list's `pop(0)` is O(n) — never use it in a loop.
queue
Part 5 · namedtuple
Immutable, hashable, low-memory record. Great for tiny value types. For anything bigger, `@dataclass` (see S014) is more ergonomic.
record
Part 6 · ChainMap
Layered lookup for config precedence (CLI > env > file > defaults). Read-only in practice; mutations only affect the first map.
compose
Part 7 · set algebra
&, |, -, ^ are the four set operators you should know. Perfect for ‘users who did A but not B’ type queries.
set
Part 8 · frozenset
Immutable set — hashable, so it can live inside dicts or other sets. Rare, but the only tool for ‘set of sets’ or ‘set as a dict key’.
immutable
Try itSolve a real interview question with the right tool

Given a stream of ~1M strings, return the top 10 most frequent. Constraints:

  1. Solve with Counter.most_common(10) — the natural Python one-liner.
  2. Solve with heapq.nlargest(10, counts.items(), key=lambda x: x[1]) — the same in HOF style.
  3. 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.)

💡 Hint · Counter for the frequencies, min-heap (from heapq) or `.most_common(k)` for the top-k. Both are O(n log k). Reviewers love that you know both.

(d) Production reality · 15 min

War story A ride-sharing SRE team · 2021P99 API latency spiked from 60 ms to 900 ms overnight
🔥 What broke

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.

🧯 The fix
Two changes: (1) 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.
🎓 Lesson to steal
Any long-lived collection queried in a hot path deserves a set/dict AND a size bound. ‘It'll never get that big’ has ended more careers than SQL injection.
War story Common failure mode · every Django/Flask app`dict changed size during iteration` at 3 am
🔥 What broke

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.x
🧯 The fix

Iterate 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.

🎓 Lesson to steal
Mutation during iteration is undefined behaviour in every mainstream language. Snapshot with list(d.items()) or build a new collection with a comprehension.
War story Common failure mode · ML/data teamsMemory blows up unexpectedly
🔥 What broke

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 fix

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).

🎓 Lesson to steal
Python dicts/sets/lists have great constant factors but zero storage discipline. Any structure whose size depends on input needs an assertion, a bound, or an external store.

Where this shows up in the rest of the plan

Data-structure choice ripples through every future session
S014 · Dataclasses & typing
@dataclass replaces namedtuple for real records. Same trade-offs, richer syntax.
S015 · Big-O
The formal treatment of what today was hand-wavy. Same lesson, deeper.
S023 · Arrays & strings
list/tuple = arrays; str is a special immutable list of chars. DSA maps onto these.
S024 · Hashmaps
Dict internals — hash function, open addressing, load factor. You'll understand WHY dict is O(1).
S045 · Pandas / Polars
DataFrames are essentially dict-of-columns. Set operations for joins, dict for group-by.
S089 · Caching
LRU = OrderedDict + move_to_end. Redis is a distributed dict + set + list combo.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. When do you use a set instead of a list? (one rule)
  2. What's defaultdict(list) for? (one concrete example)
  3. Why is deque better than list for 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.