Advanced
Standard-Library Tour
The standard library ships high-leverage tools that replace hand-rolled code — collections (Counter, defaultdict, deque, namedtuple), itertools (lazy combinatorics), and functools (reduce, lru_cache, partial). The idioms that turn ten lines into one, with each tool's sharp edge shown.
Suggest an editThe Standard-Library Tour — collections, itertools, functools
Python's slogan is "batteries included," and the batteries that pay off most for everyday code live in three modules. The thesis: collections, itertools, and functools give you specialised containers and composable building blocks that replace whole patterns of hand-written code — often turning a ten-line loop into a one-liner that's faster and clearer. Knowing they exist is half the battle; this chapter is a tour of the highest-leverage tools and the one gotcha each carries.
💡 The core idea.
collections,itertools, andfunctoolsare the highest-leverage batteries.- They give specialised containers and composable building blocks.
- They replace whole patterns of hand-written code.
- Each carries one gotcha worth knowing.
This builds on dicts & sets, iterators, and decorators. Every output below was produced by running the code.
📘 How to read the Intuition boxes. Each one is built in three moves:
- The mechanism — what the interpreter is actually doing.
- A concrete bite — a specific, runnable way the naive assumption fails.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of Contents
1. Counter and defaultdict
collections.Counter tallies occurrences; defaultdict supplies a default for missing keys. Both eliminate the boilerplate around the counting idiom from Tier 2.
Output:
Counter({'a': 3, 'n': 2, 'b': 1})
[('a', 3)]
{'a': [1, 2]}Analysis. Counter("banana") tallied every character in one call — replacing the whole d.get(ch, 0) + 1 loop — and .most_common(1) returned the top entry. defaultdict(list) let d["a"].append(1) work even though "a" didn't exist yet: the first access auto-created an empty list. Both are dict subclasses, so everything you know about dicts still applies.
Intuition.
Mechanism. Counter is a dict mapping items to counts, built in C; its repr orders by descending count. defaultdict(factory) overrides the missing-key behaviour: on a miss it calls factory() (here list()), inserts the result, and returns it — so the key now exists.
Concrete bite. That auto-creation has a flip side worth knowing: a Counter returns 0 for a missing key without inserting it, but merely reading a missing key from a defaultdict creates it. With Counter:
0c["z"] is 0 — convenient, and (unlike defaultdict) it doesn't add "z" to the counter. The trap is the reverse: some_defaultdict[missing] silently grows the dict, so checking membership with [] on a defaultdict pollutes it.
💡 Earned rule. Reach for Counter to tally and rank (most_common), and defaultdict(list/int/set) to group or accumulate without pre-initialising keys. The cost is the defaultdict surprise — reading a missing key inserts it — so use key in d (not d[key]) to test membership on a defaultdict.
2. deque
collections.deque ("deck") is a double-ended queue: O(1) append and pop at both ends, unlike a list, where front operations are O(n) (Tutorial 12).
Output:
deque([0, 1, 2, 3, 4])
0
deque([1, 2, 3, 4])Analysis. appendleft/popleft add and remove at the front in O(1) — the operations a list does in O(n) because it must shift every element. deque is the right structure for queues, sliding windows, and breadth-first search, where you push and pop at the ends.
Intuition.
Mechanism. A deque is a doubly-linked structure of blocks, so both ends are cheap; the trade is that random access by index is O(n) (it has to walk), whereas a list indexes in O(1). Different structure, different fast operations.
Concrete bite. A handy feature with a sharp edge: a maxlen deque silently drops items from the far end when it overflows:
deque([2, 3], maxlen=2)
deque([3, 4], maxlen=2)Creating it with maxlen=2 immediately discarded the oldest (1 → deque([2, 3])), and each append drops another from the left (deque([3, 4])). Perfect for "last N items," but if you didn't expect the dropping, data vanishes silently.
💡 Earned rule. Use deque for FIFO queues and both-ends work (appendleft/popleft), and maxlen for a fixed-size rolling buffer. The cost is O(n) indexing — if you need fast random access by position, keep a list; if you need a fast front, keep a deque.
3. itertools
itertools provides lazy, composable iterators (Tutorial 17) for chaining, slicing, and combinatorics — building blocks you combine instead of writing loops.
Output:
[1, 2, 3, 4]
[10, 11, 12]
[('a', 'b'), ('a', 'c'), ('b', 'c')]
['a2', 'b3', 'c1']Analysis. chain flattens several iterables into one stream; islice takes a slice of any iterator (here the first 3 of the infinite count(10)); combinations yields all 2-element combos; groupby groups consecutive equal items. All are lazy — they yield on demand — which is why islice can safely slice an infinite count.
Intuition.
Mechanism. These return iterators, computing each item only when pulled. groupby in particular scans linearly and starts a new group whenever the key changes — it groups consecutive runs, not all equal items globally.
Concrete bite. That "consecutive" rule is the classic groupby trap — unsorted input produces split groups:
a 2
b 2
a 1The string "aabba" has three as total, but groupby reports two a-groups (a 2 and a later a 1) because the as aren't all adjacent — the bb splits them. To group globally, sort first (groupby(sorted(data))).
💡 Earned rule. Compose itertools for lazy pipelines — chain/islice/combinations/product/groupby cover a huge range of loop-shaped problems. The cost/boundary: groupby only groups consecutive runs (sort first for global grouping), and the results are single-use iterators — wrap in list() if you need them twice.
4. functools
functools operates on functions. The headliners: reduce (fold a sequence to one value), lru_cache (memoise — a decorator), and partial (pre-fill arguments).
Output:
24
832040
CacheInfo(hits=28, misses=31, maxsize=128, currsize=31)
20Analysis. reduce folded [1,2,3,4] with * to 24. @lru_cache made the naively-exponential fib linear by caching results — cache_info() shows 31 unique computations (misses) and 28 cache hits, so each fib(n) was computed once. partial(f, 2) produced a new function with the first argument fixed at 2, so double(10) is 2 * 10.
Intuition.
Mechanism. reduce(f, seq) applies f cumulatively: f(f(f(1,2),3),4). lru_cache wraps the function in a memo dict keyed by the arguments (which must be hashable). partial returns a new callable that remembers some arguments.
Concrete bite. reduce on an empty sequence with no initial value has nothing to return, so it raises:
Traceback (most recent call last):
File "/w/main.py", line 2, in <module>
print(functools.reduce(lambda a, b: a + b, []))
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: reduce() of empty iterable with no initial valueWith no items and no seed, there's no value to produce — TypeError. Always pass an initial value (reduce(f, seq, 0)) when the sequence might be empty.
💡 Earned rule. Use lru_cache to memoise pure functions with hashable arguments (huge wins on recursion), partial to specialise functions, and reduce sparingly — a plain loop or sum/math.prod is often clearer. The cost: lru_cache keeps results forever (memory) and needs hashable args; reduce with no initial value crashes on empty input.
5. namedtuple
collections.namedtuple builds a lightweight, immutable class with named fields — a tuple you can read by name, ideal for small records before reaching for a full class or dataclass.
Output:
1 2
Point(x=1, y=2)
1
1 2Analysis. Point is a new type with fields x and y. An instance reads by name (p.x), prints with a clear repr (Point(x=1, y=2)), and — because it is a tuple — also indexes (p[0]) and unpacks (x, y = p). You get readability and tuple behaviour for almost no code.
Intuition.
Mechanism. namedtuple generates a subclass of tuple with property accessors for the field names. So it has all tuple powers (indexing, unpacking, hashable, immutable) plus name access — and the immutability comes straight from tuple.
Concrete bite. Being a tuple, it's immutable — you cannot assign to a field:
Traceback (most recent call last):
File "/w/main.py", line 4, in <module>
p.x = 2 # namedtuples are immutable
^^^
AttributeError: can't set attributep.x = 2 raises AttributeError — a namedtuple is read-only, like the tuple it is. To "change" one, build a new instance (p._replace(x=2)).
💡 Earned rule. Use namedtuple for small immutable records where you want name access and tuple behaviour with zero boilerplate; reach for a @dataclass (Tutorial 27) when you need mutability, methods, or defaults. The cost is immutability (a feature for hashability/safety, a constraint when you need to mutate — use _replace).
6. Mental-model summary
| Principle | Consequence |
|---|---|
Counter tallies; defaultdict(factory) auto-fills missing keys |
Counter[x] is 0 (no insert); reading a defaultdict miss inserts it |
deque is O(1) at both ends |
Use for queues/windows; indexing is O(n); maxlen drops overflow silently |
itertools are lazy, composable iterators |
groupby groups only consecutive runs — sort first for global groups |
functools: reduce, lru_cache, partial |
lru_cache needs hashable args; reduce([]) with no initial → TypeError |
namedtuple = immutable tuple with named fields |
Name access + tuple powers; can't assign fields (use _replace) |
7. Gotcha checklist
- A
defaultdictgrew keys I only read → reading a missing key inserts it; test withkey in d, notd[key]. dequeindexing felt slow → it's O(n); use a list for random access, a deque for the ends.maxlendeque lost data → by design it drops the far end on overflow.groupbysplit a group → it only groups consecutive equal keys;groupby(sorted(data))for global grouping.reduce()of empty iterable → pass an initial value:reduce(f, seq, start).AttributeError: can't set attributeon a namedtuple → it's immutable; usep._replace(field=value).
🧪 Predict, then check. Use Counter on "mississippi" and predict .most_common(2). Then predict the output of itertools.groupby("aaabbbaaa") (how many groups, and why?) versus groupby(sorted("aaabbbaaa")). Finally, memoise a recursive factorial with @lru_cache and predict cache_info() after calling factorial(5) once. The groupby one is the trap that catches everyone.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.