The collections Module: Beyond Lists and Dicts
Python’s collections module ships a small set of container datatypes that are specialized enough to deserve their own classes. Lists, dicts, tuples, and sets are the workhorses. Reach for collections when you catch yourself writing the same boilerplate over and over: counting items, grouping by key, managing a sliding window, building immutable records. The module is worth knowing well because the gains in clarity and speed are real.
The collections module at a glance
Six tools are worth learning first:
Counteris adictfor counting hashable things.defaultdictis adictthat auto-creates entries on lookup.dequeis a thread-safe double-ended queue with O(1) ends.namedtupleis a tuple subclass with named fields.ChainMapis a stack of mappings you can search in order.OrderedDictis adictwith efficient reordering and order-sensitive equality.
There is also a trio of wrapper classes (UserDict, UserList, UserString) that exist mostly for library authors who need to subclass the built-in types safely. They get a brief mention near the end.
Counter: tally without the boilerplate
Counting occurrences is one of those tasks everyone writes by hand the first time:
votes = ["yes", "no", "yes", "yes", "no", "yes"]
counts = {}
for v in votes:
counts[v] = counts.get(v, 0) + 1
print(counts)
# output: {'yes': 4, 'no': 2}
Counter cuts that to a single line and gives you extra methods for free. The construction is the same as a regular dict call: pass an iterable to Counter and you get back a dict-like object with counts as values. From there, methods like most_common, elements, and the arithmetic operators handle patterns that would otherwise take five or six lines of imperative code:
from collections import Counter
votes = ["yes", "no", "yes", "yes", "no", "yes"]
counts = Counter(votes)
print(counts)
# output: Counter({'yes': 4, 'no': 2})
print(counts.most_common(1))
# output: [('yes', 4)]
A few things are easy to miss:
- Reading a missing key returns
0, notKeyError. That makes totalising a stream safe without.get(k, 0). - Setting a count to zero does not delete the entry. You need
del counts[key]for that. It trips people up the first time. counts.total()(added in Python 3.10) sums every count, including zero and negative values. Useful for normalised tallies.c.most_common()[:-n-1:-1]returns the n least common elements. It is a documented idiom in the official docs and saves you a sort.
Counter also supports multiset arithmetic. If you treat counters as multi-sets, +, -, &, and | give you intersection, union, and difference with a twist: negative and zero counts are dropped from the result.
a = Counter(a=3, b=1)
b = Counter(a=1, b=2)
print(a + b)
# output: Counter({'a': 4, 'b': 3})
print(a - b)
# output: Counter({'a': 2})
This is great for word counts across documents, log analysis, and any time you want to combine or compare tallies without writing the merge by hand.
defaultdict: the missing-key helper
defaultdict calls a zero-argument factory the first time you look up a missing key. That is the whole idea, and it gets rid of the if key in d check that clutters group-by code.
from collections import defaultdict
fruits = ["apple", "banana", "cherry", "avocado", "blueberry", "apricot"]
by_letter = defaultdict(list)
for fruit in fruits:
by_letter[fruit[0]].append(fruit)
for letter, group in sorted(by_letter.items()):
print(letter, group)
# output:
# a ['apple', 'avocado', 'apricot']
# b ['banana', 'blueberry']
# c ['cherry']
The factory has to take no arguments. Common choices are list, int, set, dict, or a lambda: SomeValue(). For pure counting, int is the classic “counter without a Counter” pattern:
word_count = defaultdict(int)
for word in "the cat sat on the mat".split():
word_count[word] += 1
print(dict(word_count))
# output: {'the': 2, 'cat': 1, 'sat': 1, 'on': 1, 'mat': 1}
There is one rule that catches almost everyone: __missing__ only fires from __getitem__. That means d.get(key) returns None and does not create an entry. Only d[key] does. If you want get-with-default behaviour that also adds the key, use d.setdefault(k, default), but defaultdict is usually cleaner and faster for the hot path.
You can also swap the factory at runtime by assigning to the default_factory attribute, which is occasionally useful when a dict’s purpose changes mid-program.
deque: fast ends, thread-safe
A deque (pronounced “deck”) is a double-ended queue. The reason to use one is that list.append and list.pop are O(1), but list.insert(0, x) and list.pop(0) are O(n) because the whole array shifts. A deque makes both ends O(1):
from collections import deque
q = deque([1, 2, 3])
q.appendleft(0)
q.append(4)
print(q)
# output: deque([0, 1, 2, 3, 4])
print(q.popleft())
# output: 0
Pass a maxlen and the deque becomes a bounded ring buffer. Appending past capacity silently drops from the opposite end, which is exactly what you want for tail -f-style logs or a sliding window of recent events:
recent = deque(maxlen=3)
for i in range(6):
recent.append(i)
print(recent)
# output:
# deque([0], maxlen=3)
# deque([0, 1], maxlen=3)
# deque([0, 1, 2], maxlen=3)
# deque([1, 2, 3], maxlen=3)
# deque([2, 3, 4], maxlen=3)
# deque([3, 4, 5], maxlen=3)
Other methods worth knowing: rotate(n) rotates the deque in place (positive rotates right, negative left), extendleft reverses the input order on purpose (because it pushes each item to the left), and deque[i] supports random access at O(n), though you probably do not want to use a deque as a list replacement.
The thread-safety story is the other reason to pick deque over list. Appending and popping from either end are protected by the GIL, so multiple threads can use a shared deque as a producer/consumer queue. Lists make no such guarantee. If you are building a multi-threaded pipeline, a bounded deque is a sensible primitive. (For anything more elaborate, the queue.Queue class is a better fit.)
namedtuple: records that cost no more than tuples
namedtuple produces a tuple subclass with named fields. The objects behave exactly like tuples, including the memory cost, but you can use attribute access instead of positional indexing.
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(1, 2)
print(p)
# output: Point(x=1, y=2)
print(p.x, p.y)
# output: 1 2
print(p[0], p[1])
# output: 1 2
Three helpers come for free:
Point._make(iterable)builds a record from any iterable. Great for rows fromcsv.readerorsqlite3.p._asdict()returns a regulardict(since Python 3.8; before that it returned anOrderedDict).p._replace(**kwargs)returns a new record with fields swapped, which is the immutable equivalent of an in-place edit.
Point2D = namedtuple("Point2D", ["x", "y"], defaults=[0])
print(Point2D())
# output: Point2D(x=0, y=0)
shifted = Point(3, 4)._replace(x=10)
print(shifted)
# output: Point(x=10, y=4)
The defaults argument (Python 3.7+) is applied right-to-left across the field list, so defaults=[0] gives the rightmost field a default and the rest stay required.
A few hard rules:
- Field names must be valid Python identifiers and must not start with
_(those are reserved for_make,_asdict,_replace, and_fields). namedtupleinstances are immutable. There is no per-instance__dict__, so memory usage matches a plain tuple.- If you need mutability, methods, or inheritance, the answer is usually
@dataclassortyping.NamedTuple, not a manualnamedtuplesubclass.
For tiny immutable records (coordinates, parsed rows, result tuples), namedtuple is still the cheapest way to make code self-documenting.
OrderedDict: when reordering is the point
Since Python 3.7, the regular dict preserves insertion order, so OrderedDict is no longer the only ordered mapping. It is still useful, but for narrower reasons:
move_to_end(key, last=True)reorders a key efficiently.popitem(last=True)pops from either end; regulardict.popitem()pops only from the right.- Equality between two
OrderedDictinstances is order-sensitive. Regulardictequality is order-insensitive across mappings.
The classic use case is an LRU cache:
from collections import OrderedDict
cache = OrderedDict()
for key in ["a", "b", "c", "d"]:
cache[key] = key.upper()
# Record a cache hit: bump to the end (most-recently-used).
cache.move_to_end("a")
print(list(cache.items()))
# output: [('b', 'B'), ('c', 'C'), ('d', 'D'), ('a', 'A')]
# Evict the least-recently-used entry.
lru_key, lru_value = cache.popitem(last=False)
print(lru_key, lru_value)
# output: b B
functools.lru_cache handles this for you, but rolling your own is a useful exercise, and OrderedDict is the natural primitive when you need fine control over eviction order.
ChainMap: layered config without copying
ChainMap groups several mappings into a single, searchable view. Lookups check each map in the order they were passed; the first match wins. There is no copying, so updates to the underlying dicts are visible immediately.
The canonical example is layered configuration: defaults < environment variables < command-line flags.
from collections import ChainMap
defaults = {"theme": "light", "verbose": False}
env = {"verbose": "1", "log_level": "INFO"}
cli = {"theme": "dark"}
config = ChainMap(cli, env, defaults)
print(config["theme"])
# output: dark
print(config["verbose"])
# output: 1
print(config["log_level"])
# output: INFO
The gotcha that makes ChainMap confusing: writes and deletes only affect the first mapping in the chain. config["x"] = 1 puts x into cli, not into env or defaults. That is the right behaviour for the config-override pattern (you want the most-specific layer to receive the edit), but it surprises people who expect a deep merge.
Two properties worth knowing:
chain.mapsis a public, mutable list. You can reorder the chain at runtime, which is occasionally useful for testing or dynamic scoping.chain.new_child(m=None)returns a newChainMapwithmprepended, leaving the parent chain untouched.chain.parentsis the opposite: a new chain that skips the first mapping.
UserDict, UserList, UserString: one line each
Subclassing dict, list, or str directly works, but with a sharp edge: the C-level methods do not always call your overrides. The UserDict, UserList, and UserString classes wrap the built-in types in a Python-level data attribute, so every method goes through the wrapper and your overrides always fire.
from collections import UserDict
class CaseInsensitiveDict(UserDict):
def __setitem__(self, key, value):
super().__setitem__(key.lower(), value)
def __getitem__(self, key):
return super().__getitem__(key.lower())
d = CaseInsensitiveDict()
d["Name"] = "Ada"
print(d["name"])
# output: Ada
If you are not writing a library that other code will subclass, you can ignore these. They are mostly here for the standard library itself and for third-party code that has to override container behaviour safely.
Common gotchas
A short list of things that bit me the first time I reached for each of these:
Counterkeeps zero counts.c['x'] = 0does not remove the key. Usedel c['x']if you need the entry gone.defaultdictis silent on.get().d.get('missing')does not create the entry. Onlyd['missing']does. Pick the right one for your intent.deque.extendleftreverses the input. It is not a bug; it is how pushing each element onto the left works. Pass an already-reversed iterable if you want left-to-right order preserved.namedtuplefield names cannot start with_. They collide with the_make/_asdict/_replace/_fieldshelpers.ChainMapwrites only touch the first mapping. Reads walk the chain, writes do not. Read the docs once before using it for anything other than read-only config layering.
Conclusion
The collections module is small on purpose. It is six classes (plus the User* wrappers) that exist because real code keeps reinventing them. When you find yourself hand-rolling a counter, a missing-key dict, a sliding window, an immutable record, a layered config, or an LRU cache, the right tool is almost certainly already in collections. The Python collections module rewards the small investment of learning the API by replacing boilerplate with intent. Pick the right container, and the code reads like what you meant.