
The collections Module: Counter, defaultdict, deque, and namedtuple
Python's built-in list, dict, set, and tuple cover most of what you need. But a surprising amount of everyday code is the same few patterns written by hand over and over: counting things in a dict, checking whether a key exists before appending to a list, popping items off the front of a list, or passing around tuples where nobody remembers what index 2 means.
The collections module in the standard library has a purpose-built container for each of those patterns. They're fast (most are implemented in C), they're well tested, and they make your intent obvious to the next person reading the code.
I'll cover the four you'll use most, Counter, defaultdict, deque, and namedtuple, with real examples of when each one earns its place. At the end there's a short tour of OrderedDict and ChainMap, which are less common but still worth knowing about. All examples run on Python 3.13.
Counter: Counting Things Without the Boilerplate
The classic hand-rolled counting loop looks like this:
counts = {}
for word in words:
if word not in counts:
counts[word] = 0
counts[word] += 1
Counter replaces all of it. It's a dict subclass that maps elements to their counts, and you can build one directly from any iterable:
from collections import Counter
words = "the cat and the hat and the bat".split()
counts = Counter(words)
print(counts)
print(counts["the"], counts["dog"])
Counter({'the': 3, 'and': 2, 'cat': 1, 'hat': 1, 'bat': 1})
3 0
Notice that looking up a missing key ("dog") returns 0 instead of raising KeyError. That's one of the main conveniences: a Counter treats anything it hasn't seen as having a count of zero. Unlike defaultdict, the lookup doesn't insert the key either.
Finding the Most Common Items
most_common(n) returns the n highest counts as a list of (element, count) pairs, sorted from most to least common:
print(counts.most_common(2))
[('the', 3), ('and', 2)]
Call it without an argument to get every element in order. Ties keep the order in which elements were first encountered.
Updating and Subtracting
You can keep feeding a Counter new data with update(), which adds counts rather than replacing them (the opposite of dict.update()). subtract() does the reverse, and it's allowed to go below zero:
inv = Counter(apples=3, pears=1)
inv.update({"apples": 2, "kiwis": 4})
print(inv)
inv.subtract({"pears": 3})
print(inv)
Counter({'apples': 5, 'kiwis': 4, 'pears': 1})
Counter({'apples': 5, 'kiwis': 4, 'pears': -2})
Negative counts are useful when you're tracking a balance, but often you want to drop them. The unary + operator returns a new Counter with only the positive counts:
print(+inv)
print(inv.total())
Counter({'apples': 5, 'kiwis': 4})
7
total() sums every count, including the negative one here (5 + 4 - 2). It was added in Python 3.10 and saves you from writing sum(inv.values()).
Counter Arithmetic
Counters support arithmetic and set-like operators, which is handy for comparing two distributions:
a = Counter("abracadabra")
b = Counter("alakazam")
print(a)
print(a - b) # subtract, keep positive results
print(a & b) # intersection: min of each count
Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
Counter({'b': 2, 'r': 2, 'a': 1, 'c': 1, 'd': 1})
Counter({'a': 4})
a + b adds counts together, and a | b takes the maximum of each count. All four operators discard results that are zero or negative.
A neat use of this: checking whether you can build one multiset out of another. Since Python 3.10, counters support inclusion comparisons:
print(Counter("aab") <= Counter("aaabc")) # True: enough letters to spell "aab"
That one line answers "can I make this word out of these Scrabble tiles?"
When Counter Is the Right Tool
- Word or token frequencies, log line tallies, HTTP status code counts.
- Finding the top N of anything.
- Comparing multisets (anagram checks, inventory differences).
If you only need to know whether something appeared, not how many times, a set is simpler and smaller. See sets in Python for that side of things.
defaultdict: Dicts That Fill Themselves In
defaultdict is a dict subclass that takes a default factory: a callable with no arguments. Whenever you access a key that doesn't exist, it calls the factory, stores the result under that key, and returns it.
The most common use is grouping:
from collections import defaultdict
orders = [("ada", "mug"), ("grace", "lamp"), ("ada", "pen"), ("linus", "mug")]
by_customer: defaultdict[str, list[str]] = defaultdict(list)
for customer, item in orders:
by_customer[customer].append(item)
print(dict(by_customer))
{'ada': ['mug', 'pen'], 'grace': ['lamp'], 'linus': ['mug']}
Without defaultdict you'd need by_customer.setdefault(customer, []).append(item) or an if check on every iteration. Here the first access to by_customer["ada"] creates an empty list automatically.
The factory can be any zero-argument callable:
| Factory | Default value | Typical use |
|---|---|---|
list | [] | Grouping items under a key |
set | set() | Grouping unique items, adjacency lists |
int | 0 | Counting or summing |
dict | {} | Nested lookups one level deep |
lambda: "n/a" | "n/a" | Any custom default |
Here's set building an undirected graph:
graph: defaultdict[str, set[str]] = defaultdict(set)
for a, b in [("a", "b"), ("a", "c"), ("b", "c")]:
graph[a].add(b)
graph[b].add(a)
The Gotcha: Reads Create Keys
The factory runs on any missing-key access with square brackets, including reads. That can quietly grow your dict:
d = defaultdict(int)
print("x" in d, d["x"], "x" in d)
print(d.get("y"), "y" in d)
False 0 True
None False
Just reading d["x"] inserted "x" with a value of 0. If you want to check for a key without creating it, use in or .get(), which don't trigger the factory. This matters when you later iterate over the dict or serialize it: phantom keys with default values tend to show up in reports and API responses.
A related tip: once you're done building a defaultdict, convert it with dict(...) before handing it to other code. That way a typo like result["adaa"] raises KeyError instead of silently returning an empty list.
defaultdict vs Counter vs setdefault
- Counting? Use
Counter. It gives youmost_common()and arithmetic, and missing-key reads don't insert keys. - Grouping into lists or sets? Use
defaultdict(list)ordefaultdict(set). - One-off default on a plain dict you don't own?
dict.setdefault(key, default)works without changing the type.
For more on how plain dicts behave, including ordering and merging, see the dictionaries deep dive.
deque: A Fast Double-Ended Queue
A list is great for appending and popping at the end. It's bad at the front. list.pop(0) and list.insert(0, x) have to shift every other element, so they're O(n). In a loop that processes a queue of 100,000 items, that turns into billions of element moves.
deque (pronounced "deck") is a double-ended queue with O(1) appends and pops on both ends:
from collections import deque
q = deque([1, 2, 3])
q.append(4)
q.appendleft(0)
print(q)
print(q.pop(), q.popleft(), q)
deque([0, 1, 2, 3, 4])
4 0 deque([1, 2, 3])
It also has extend(), extendleft(), rotate(), clear(), count(), and remove(). rotate(n) shifts everything right by n steps (left if n is negative):
q.rotate(1)
print(q) # deque([3, 1, 2])
The trade-off: indexing into the middle of a deque (q[50_000]) is O(n), and deques don't support slicing. If you need random access, stick with a list.
Using deque as a Queue
Breadth-first search is the textbook case. You pop from the left and push to the right:
from collections import deque
def bfs(graph: dict[str, list[str]], start: str) -> list[str]:
seen = {start}
queue = deque([start])
order = []
while queue:
node = queue.popleft()
order.append(node)
for nxt in graph[node]:
if nxt not in seen:
seen.add(nxt)
queue.append(nxt)
return order
g = {"a": ["b", "c"], "b": ["d"], "c": ["d"], "d": []}
print(bfs(g, "a"))
['a', 'b', 'c', 'd']
The same pattern works for job queues, undo/redo stacks, and any producer/consumer flow inside a single thread. (append() and popleft() are thread-safe for a deque, but if you need blocking behavior between threads, queue.Queue is the better fit.)
Bounded Deques with maxlen
Pass maxlen and the deque never grows beyond that size. When it's full, adding to one end silently discards from the other:
recent = deque(maxlen=3)
for n in range(6):
recent.append(n)
print(recent)
deque([3, 4, 5], maxlen=3)
That gives you "the last N things" for free. A few places it shines:
from collections import deque
from collections.abc import Iterable, Iterator
def tail(lines: Iterable[str], n: int = 10) -> deque[str]:
"""Return the last n lines, like the Unix tail command."""
return deque(lines, maxlen=n)
def moving_average(values: Iterable[float], window: int = 3) -> Iterator[float]:
buf: deque[float] = deque(maxlen=window)
for v in values:
buf.append(v)
yield sum(buf) / len(buf)
print(list(moving_average([10, 20, 30, 40, 50])))
[10.0, 15.0, 20.0, 30.0, 40.0]
tail(open("app.log")) reads the file once and keeps only the final lines in memory, no matter how large the file is. The moving average works on any stream, including an infinite generator.
namedtuple: Tuples with Field Names
Tuples are a nice way to return several values, but positional access gets unreadable fast. What is row[2]? namedtuple creates a tuple subclass whose fields also have names:
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
print(p, p.x, p[1])
x, y = p # still unpacks like a tuple
Point(x=3, y=4) 3 4
You get a readable repr, attribute access, and everything a tuple does: indexing, unpacking, hashing, comparison, and immutability. They use no more memory than a regular tuple, because field names live on the class, not on each instance.
Fields are read-only:
try:
p.x = 5
except AttributeError as e:
print("AttributeError:", e)
AttributeError: can't set attribute
Useful namedtuple Methods
The helper methods start with an underscore so they can't collide with your field names:
print(p._replace(x=10)) # new instance with one field changed
print(p._asdict()) # plain dict
print(Point._fields) # field names
print(Point._make([5, 6])) # build from any iterable
Point(x=10, y=4)
{'x': 3, 'y': 4}
('x', 'y')
Point(x=5, y=6)
_make() is great for turning rows from csv.reader or a database cursor into named records. You can also give defaults to the rightmost fields:
Color = namedtuple("Color", "red green blue alpha", defaults=[1.0])
print(Color(255, 0, 0)) # Color(red=255, green=0, blue=0, alpha=1.0)
typing.NamedTuple: The Modern Syntax
For new code, I'd reach for typing.NamedTuple instead. It builds the same kind of class but uses a class body, so you get type hints, defaults, docstrings, and methods in a familiar form:
from typing import NamedTuple
class Employee(NamedTuple):
name: str
dept: str
salary: int = 50_000
def monthly(self) -> float:
return self.salary / 12
e = Employee("Ada", "Eng", 120_000)
print(e, e.monthly())
print(Employee("Bob", "Ops"))
Employee(name='Ada', dept='Eng', salary=120000) 10000.0
Employee(name='Bob', dept='Ops', salary=50000)
namedtuple vs dataclass
If you need mutability, validation in __post_init__, or don't want your record to behave like a sequence, a dataclass is a better fit. A named tuple is a tuple, so Point(1, 2) == (1, 2) is True, and it can be unpacked or indexed by accident. That's a feature when you're replacing existing tuples without breaking callers, and a liability when you're designing a new type from scratch.
namedtuple / NamedTuple | @dataclass | |
|---|---|---|
| Mutable | No | Yes (unless frozen=True) |
| Behaves like a tuple | Yes | No |
| Hashable by default | Yes | Only if frozen |
| Memory | Same as a tuple | Like a regular object (less with slots=True) |
| Best for | Lightweight, immutable records; tuple-compatible returns | General-purpose data classes |
The lists vs tuples post goes deeper on when immutability is the right call.
Two More Worth Knowing: OrderedDict and ChainMap
OrderedDict
Since Python 3.7, regular dicts keep insertion order, so OrderedDict is mostly unnecessary. It still has two things plain dicts don't: move_to_end() and popitem(last=False), which make it a natural building block for an LRU cache.
from collections import OrderedDict
od = OrderedDict(a=1, b=2, c=3)
od.move_to_end("a")
print(od)
print(od.popitem(last=False)) # remove the oldest item
OrderedDict({'b': 2, 'c': 3, 'a': 1})
('b', 2)
Equality also differs: two OrderedDicts are only equal if their order matches, while two plain dicts with the same items are equal in any order.
ChainMap
ChainMap groups several mappings into one view. Lookups search each mapping in order and return the first hit; writes go to the first mapping only. It's a clean way to layer configuration:
from collections import ChainMap
defaults = {"theme": "light", "lang": "en"}
user = {"theme": "dark"}
cfg = ChainMap(user, defaults)
print(cfg["theme"], cfg["lang"])
cfg["lang"] = "fr"
print(user, defaults)
dark en
{'theme': 'dark', 'lang': 'fr'} {'theme': 'light', 'lang': 'en'}
The defaults dict is never modified, and no data is copied. Add a third layer for environment variables or command-line flags and the precedence order is right there in the constructor.
Quick Reference
| Need | Use |
|---|---|
| Count occurrences, top N | Counter |
| Group values under keys | defaultdict(list) / defaultdict(set) |
| Queue, stack at both ends, sliding window | deque (with maxlen for bounded) |
| Immutable record with field names | typing.NamedTuple |
| Dict with reorder operations | OrderedDict |
| Layered lookups without copying | ChainMap |
Conclusion
The collections module is one of the highest-value imports in the standard library. Counter turns counting loops into one line, defaultdict removes the "does this key exist yet?" dance, deque gives you queues that don't slow down as they grow, and namedtuple makes tuple-shaped data readable without giving up anything tuples do.
None of these are exotic. If you find yourself writing if key not in d: d[key] = [] or items.pop(0), that's your cue to reach for the right container instead.


