Type something to search...
The collections Module: Counter, defaultdict, deque, and namedtuple

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:

FactoryDefault valueTypical use
list[]Grouping items under a key
setset()Grouping unique items, adjacency lists
int0Counting 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 you most_common() and arithmetic, and missing-key reads don't insert keys.
  • Grouping into lists or sets? Use defaultdict(list) or defaultdict(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
MutableNoYes (unless frozen=True)
Behaves like a tupleYesNo
Hashable by defaultYesOnly if frozen
MemorySame as a tupleLike a regular object (less with slots=True)
Best forLightweight, immutable records; tuple-compatible returnsGeneral-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

NeedUse
Count occurrences, top NCounter
Group values under keysdefaultdict(list) / defaultdict(set)
Queue, stack at both ends, sliding windowdeque (with maxlen for bounded)
Immutable record with field namestyping.NamedTuple
Dict with reorder operationsOrderedDict
Layered lookups without copyingChainMap

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.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading