The complete Python collections module reference: Counter, defaultdict, deque, and namedtuple with tested examples and performance notes, plus quick guidance on OrderedDict and ChainMap, and a clear signal for when to reach for each one instead of the built-in types.
“Program testing can be used to show the presence of bugs, but never to show their absence!”
Edsger Dijkstra, Notes on Structured Programming
Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 14 minutes
Your built-in dict, list, and tuple handle most of what you do every day. The Python collections module is for the leftover cases, the ones where you catch yourself writing the same little workaround again and again to patch a gap in the built-in types. Counting items? Counter. Need a default value the first time you touch a missing key? defaultdict. Fast adds and removes on both ends? deque. A small, frozen record with named fields? namedtuple. Think of it like the drawer of specialised kitchen tools next to the everyday knife and spoon: you do not reach for the garlic press often, but when you need it, nothing else comes close.
Every type below comes with a tested example and a plain answer to the only question that matters: when do you actually reach for it instead of a plain dict or list? The Quick Reference table is right at the top, so if you landed here from a search for one specific type, grab what you need and go.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The diagram maps six specialized container types from Python’s collections module: Counter for frequency counting, defaultdict for auto-initializing missing keys, OrderedDict for insertion-order guarantees, deque for efficient double-ended queues, namedtuple for lightweight immutable records, and ChainMap for searching multiple dictionaries as one. Each type solves a specific pain point that regular dicts and lists handle awkwardly. Choosing the right container from this map can turn 10 lines of code into one.
Table of Contents
Quick Reference
| Type | Replaces | Key Feature | Use When |
|---|---|---|---|
Counter | Manual counting with dict | Counts hashable objects | Word frequency, voting, histogram |
defaultdict | dict.setdefault() / if-key checks | Auto-creates missing keys | Grouping, accumulating, nested dicts |
deque | list (slow left ops) | O(1) append/pop both ends | Queues, sliding windows, breadth-first search (BFS) |
namedtuple | Tuples with index access | Named fields, immutable | Lightweight records, comma-separated values (CSV) rows |
OrderedDict | dict (mostly) | Reordering methods | Least recently used (LRU) caches, ordered serialization |
ChainMap | Multiple dict lookups | Stacks dicts as single view | Config layers, variable scopes |
Counter: Counting Made Trivial
Picture the tally marks you scribble on a whiteboard while counting votes: one stroke per vote, grouped by name. Counter is that whiteboard. Hand it any iterable and it counts how often each item shows up, then hands you methods like most_common() on top. It is a real dict subclass, so anything you can do to a dict still works. In the example below we count the letters in a colleague’s name (say a developer named Prathamesh), tally customer feedback votes, and merge match points scored across two games by four players: Rahul, Viraj, Niranjan, and Sardar.
📄 counter_basics.py: count anything hashable
from collections import Counter
# Count characters
char_counts = Counter("prathamesh")
print(char_counts)
print(char_counts.most_common(3))
# Count words
feedback = ["good", "excellent", "good", "average", "excellent", "good", "average"]
votes = Counter(feedback)
print(f"\nFeedback results: {votes}")
print(f"Winner: {votes.most_common(1)[0]}")
# Arithmetic on Counters
team_a = Counter({"Rahul": 3, "Viraj": 5, "Niranjan": 2})
team_b = Counter({"Rahul": 4, "Viraj": 1, "Sardar": 6})
print(f"\nCombined: {team_a + team_b}")
print(f"Difference: {team_a - team_b}") # Only positive counts kept
▶ Output
Counter({'a': 2, 'h': 2, 'p': 1, 'r': 1, 't': 1, 'm': 1, 'e': 1, 's': 1})
[('a', 2), ('h', 2), ('p', 1)]
Feedback results: Counter({'good': 3, 'excellent': 2, 'average': 2})
Winner: ('good', 3)
Combined: Counter({'Rahul': 7, 'Viraj': 6, 'Sardar': 6, 'Niranjan': 2})
Difference: Counter({'Viraj': 4, 'Niranjan': 2})
What happened here: one constructor call did all the counting. most_common(3) handed back the top three by count, already sorted. The arithmetic is the part people miss: adding two Counters sums the matching keys, and subtracting them drops any count that falls to zero or below, which is why Rahul and Sardar vanish from the difference. That is a feature, not a bug. If you want the negatives kept, use subtract() instead of -.
defaultdict: No More KeyError
With a plain dict, touching a key that does not exist yet raises KeyError, so you end up guarding every access with an if-check or setdefault(). A defaultdict skips all that. You tell it once what an empty value looks like (an empty list, a zero, an empty set), and the first time you reach for a missing key, it quietly creates that value for you. It is like a new notebook where every page already exists: flip to any name and there is a blank line ready to write on, no “page not found”. The example below groups a small office team by department: engineers Rahul, Anvi, and Anvay, designers Aditi and Vinay, and Aviraj from marketing.
📄 defaultdict_basics.py: auto-create missing keys
from collections import defaultdict
# Group items by category
employees = [
("Engineering", "Rahul"),
("Engineering", "Anvi"),
("Design", "Aditi"),
("Engineering", "Anvay"),
("Design", "Vinay"),
("Marketing", "Aviraj"),
]
# Without defaultdict: need if-key-exists checks everywhere
# With defaultdict: just append, the missing key auto-creates an empty list
by_department = defaultdict(list)
for dept, name in employees:
by_department[dept].append(name)
for dept, team in by_department.items():
print(f"{dept}: {', '.join(team)}")
# defaultdict(int) is handy for counting (though Counter is better at it)
word_counts = defaultdict(int)
for word in "to be or not to be".split():
word_counts[word] += 1
print(f"\nWord counts: {dict(word_counts)}")
# defaultdict(set) accumulates unique values
tags = defaultdict(set)
tags["python"].add("tutorial")
tags["python"].add("beginner")
tags["python"].add("tutorial") # Duplicate, ignored
# A set has no guaranteed order, so sort it before printing for a stable result
print(f"Unique python tags: {sorted(tags['python'])}")
▶ Output
Engineering: Rahul, Anvi, Anvay
Design: Aditi, Vinay
Marketing: Aviraj
Word counts: {'to': 2, 'be': 2, 'or': 1, 'not': 1}
Unique python tags: ['beginner', 'tutorial']
What happened here: the grouping loop never once checks whether a department already exists. The first time by_department["Design"] is touched, the factory you passed (list) runs and drops an empty list in place, ready for the append. The defaultdict(int) version does the same trick with zeros, so += 1 works even on a key you have never seen. One thing worth saying out loud: the duplicate "tutorial" simply does nothing, because a set keeps only unique values. We sort the set before printing it, since a set has no fixed order and you would otherwise see the two tags land in either order from run to run.
deque: The Double-Ended Powerhouse
A deque (say it “deck”, short for double-ended queue) is a list that is fast at both ends. Adding or removing from the front of a normal list is slow, because every other element has to shuffle over by one spot, the same way the whole line at a coffee counter has to step forward when the person at the front leaves. A deque is built so both ends move in constant time, no shuffling. That makes it the right tool for queues, for sliding windows, and for any “add here, drop from there” pattern.
If you want the computer science view of those structures, the stacks and queues tutorial builds both from scratch and shows exactly where deque fits. The rotate demo below cycles a weekly on-call roster of our four engineers, Anvi, Aditi, Anvay, and Aviraj, without rebuilding the list.
📄 deque_basics.py: O(1) operations on both ends
from collections import deque
# List vs deque for left operations
# list.insert(0, x) is O(n): shifts everything over
# deque.appendleft(x) is O(1): instant
d = deque([1, 2, 3])
d.appendleft(0) # [0, 1, 2, 3]
d.append(4) # [0, 1, 2, 3, 4]
d.popleft() # returns 0, removed from the left
d.pop() # returns 4, removed from the right
print(f"After ops: {list(d)}")
# Sliding window with maxlen
recent_temps = deque(maxlen=5)
for temp in [22, 24, 19, 27, 30, 25, 28]:
recent_temps.append(temp)
print(f"Window: {list(recent_temps)}, Avg: {sum(recent_temps)/len(recent_temps):.1f}")
# Rotate
tasks = deque(["Anvi", "Aditi", "Anvay", "Aviraj"])
tasks.rotate(1) # Rotate right by 1
print(f"\nAfter rotate(1): {list(tasks)}")
tasks.rotate(-2) # Rotate left by 2
print(f"After rotate(-2): {list(tasks)}")
▶ Output
After ops: [1, 2, 3] Window: [22], Avg: 22.0 Window: [22, 24], Avg: 23.0 Window: [22, 24, 19], Avg: 21.7 Window: [22, 24, 19, 27], Avg: 23.0 Window: [22, 24, 19, 27, 30], Avg: 24.4 Window: [24, 19, 27, 30, 25], Avg: 25.0 Window: [19, 27, 30, 25, 28], Avg: 25.8 After rotate(1): ['Aviraj', 'Anvi', 'Aditi', 'Anvay'] After rotate(-2): ['Aditi', 'Anvay', 'Aviraj', 'Anvi']
What happened here: after pushing one value onto each end and popping one off each end, we are back to [1, 2, 3]. The sliding window is the part to remember. With maxlen=5, the deque never holds more than five items: the moment a sixth arrives, the oldest one on the left falls off on its own, which is exactly what you want for a “last five readings” average. No manual trimming. rotate(1) shifts everything one step to the right and wraps the last item around to the front, and a negative count rotates the other way.
namedtuple: Tuples With Names
A plain tuple stores a row of values, but you have to remember that employee[2] is the department and employee[3] is the salary. A namedtuple keeps everything a tuple gives you (it is small, immutable, hashable, iterable) and adds names on top, so you can write employee.department instead of counting positions. Think of a labelled spice rack versus an unlabelled one: same jars, but now you grab the right one without tasting each. Below we model two employees, Rahul Mahadik from Engineering and Vinay Mishra from Design, as frozen records.
📄 namedtuple_basics.py: lightweight immutable records
from collections import namedtuple
# Create a named tuple class
Employee = namedtuple("Employee", ["name", "age", "department", "salary"])
# Create instances
rahul = Employee("Rahul Mahadik", 29, "Engineering", 95000)
vinay = Employee("Vinay Mishra", 27, "Design", 78000)
# Access by name (readable) or index (compatible with tuple)
print(f"{rahul.name} in {rahul.department}")
print(f"Same thing: {rahul[0]} in {rahul[2]}")
# Immutable: you can't change fields in place
# rahul.salary = 100000 # AttributeError!
# _replace creates a NEW namedtuple with changed values
promoted = rahul._replace(salary=110000, department="Lead Engineering")
print(f"\nPromoted: {promoted}")
# Convert to dict
print(f"As dict: {rahul._asdict()}")
# Use as dict keys (tuples are hashable!)
team_leads = {rahul: "Team Alpha", vinay: "Team Beta"}
print(f"\n{rahul.name} leads: {team_leads[rahul]}")
▶ Output
Rahul Mahadik in Engineering
Same thing: Rahul Mahadik in Engineering
Promoted: Employee(name='Rahul Mahadik', age=29, department='Lead Engineering', salary=110000)
As dict: {'name': 'Rahul Mahadik', 'age': 29, 'department': 'Engineering', 'salary': 95000}
Rahul Mahadik leads: Team Alpha
What happened here: the same value reads two ways. rahul.name and rahul[0] point at the very same thing, so old code that indexes by position keeps working while new code can use the readable names. Because the record is immutable, _replace does not edit it, it builds a fresh namedtuple with your changes and leaves the original untouched (notice the original rahul still shows the old department and salary in the dict line). And since tuples are hashable, you can drop a whole record straight into a dict as a key, which a list could never do.
Modern alternative: a dataclass (added in Python 3.7, covered in the dataclasses tutorial) is the more flexible pick when you need to change fields after creation, give fields default values, or attach methods. Reach for namedtuple when you specifically want the record frozen and fully tuple-compatible.
Head-to-Head: When to Use Which
| Need | Use | NOT |
|---|---|---|
| Count occurrences | Counter | defaultdict(int) (works but Counter is richer) |
| Group items by key | defaultdict(list) | Manual if-key-exists check |
| Queue / BFS | deque | list (O(n) popleft) |
| Fixed-size sliding window | deque(maxlen=n) | Manual list trimming |
| Immutable record | namedtuple | Plain tuple with index access |
| Mutable record with defaults | dataclass (covered in the dataclasses tutorial) | namedtuple (immutable) |
| Config layer stacking | ChainMap | Merging dicts with | |
Common Mistakes
❌ Mistake: defaultdict creates entries on ANY access
from collections import defaultdict
d = defaultdict(list)
# This creates the key! Even though you only meant to "check" it
if d["missing_key"]: # creates d["missing_key"] = []
pass
print(len(d)) # 1, the key now exists!
# Fix: use 'in' to check without creating anything
if "another_key" in d:
pass
print(len(d)) # still 1
Why it bites: on a defaultdict, reading d["missing_key"] is not a harmless peek. The lookup itself triggers the factory and inserts the key, so a simple “is this here?” check silently grows your dict. It is like a hotel that builds a brand new room the moment anyone asks whether that room exists: just asking changes the building. When you only want to know whether a key is present, use "key" in d, which never creates anything.
❌ Mistake: Using list as a queue
# list.pop(0) is O(n): every remaining element shifts left
# For queues, ALWAYS use deque:
from collections import deque
queue = deque()
queue.append("task1") # enqueue
queue.append("task2")
task = queue.popleft() # dequeue, O(1)!
Wrapping Up
You now have the four workhorses of the collections module in hand: Counter for counting anything hashable, defaultdict for grouping and accumulating without KeyError guards, deque for constant-time work at both ends, and namedtuple for readable frozen records, plus a clear sense of when OrderedDict and ChainMap still earn their keep. The pattern behind all of them is the same: if you keep writing the same little workaround on top of a dict or list, the Python collections module has probably already shipped the fix. Next in the series we tackle datetime and time, where dates, timezones, and formatting get the same practical treatment.
New here, or want the full path from basics to AI/ML? Start at the Python + AI/ML tutorial series home and pick up wherever you are.
Frequently Asked Questions
What is the collections module in Python?
The collections module provides specialized container datatypes that extend Python’s built-in dict, list, tuple, and set. Key types include Counter (counting), defaultdict (auto-defaults), deque (double-ended queue), namedtuple (named fields), OrderedDict, and ChainMap. Everything in Python collections ships with the standard library, so there is nothing to install.
What is the difference between Counter and defaultdict(int)?
Both can count occurrences, but Counter is purpose-built with extra features: most_common(n), arithmetic between Counters (+, -, &, |), and elements() to iterate over counted items. Use Counter for counting; use defaultdict(int) when you need a general-purpose dict with numeric defaults.
When should I use deque instead of list?
Use deque when you need O(1) operations on both ends: appendleft(), popleft(), or maxlen for sliding windows. A list’s pop(0) and insert(0, x) are O(n). For queues, BFS, and sliding windows, deque is always the right choice.
namedtuple vs dataclass: which should I use?
Use namedtuple for immutable, lightweight records that need tuple compatibility (hashable, iterable). Use dataclass (covered in the dataclasses tutorial) for mutable records that need default values, methods, inheritance, or __post_init__. In modern Python, dataclasses are usually the better default.
Do I still need OrderedDict in Python 3.7+?
Regular dicts preserve insertion order since Python 3.7. OrderedDict is still useful for move_to_end(), popitem(last=True/False), and when order-equality matters (OrderedDict considers order in ==, regular dicts don’t).
Try It Yourself
Build a RecentSearches class using deque(maxlen=10) that stores the last 10 searches. Add a frequency() method that returns a Counter of how often each term was searched. Test it: add 15 searches and verify only the last 10 are kept. The exercise pairs two Python collections types you will keep reaching for in real code.
Interview Questions on Python Collections
Scenario questions, not trivia: this is the form this topic takes in a real interview.
Q: You profile a slow background worker and find list.pop(0) at the top of the report. What is going wrong, and what do you change?
list.pop(0) is O(n): every remaining element shifts one slot left, so as the queue grows under load, each dequeue gets slower and the backlog compounds. Switch the queue to collections.deque and use popleft(), which is O(1) at both ends. If multiple threads need to share the queue with blocking behaviour, step up to queue.Queue, which is built on a deque internally.
Q: Your service keeps a defaultdict(list) as an in-process cache, and memory climbs steadily even on requests that only read data. What do you check first?
Look for reads done with square brackets. On a defaultdict, d[key] on a missing key runs the factory and inserts the key, so an innocent check like if d[user_id]: quietly adds one empty list per unseen user, forever. Replace read-only lookups with key in d or d.get(key), neither of which creates entries.
Q: Does counter[“missing”] raise KeyError on a Counter? How is this different from defaultdict?
No. Counter overrides __missing__ to return 0 for keys it has never counted, and, unlike defaultdict, the read does not insert the key. So you can safely test counter["missing"] > 0 without growing the Counter, whereas the same bracket access on a defaultdict(int) would create the key with value 0.
Q: Why does subtracting one Counter from another sometimes “lose” keys, and how do you keep negative counts?
The - operator is defined to keep only positive counts: any key whose result is zero or negative is dropped from the new Counter. That is by design, since counts are usually meant to be non-negative. If you need the negatives preserved, call c1.subtract(c2) instead, which mutates c1 in place and keeps zero and negative values.
Q: What makes a namedtuple usable as a dictionary key, and when would that break?
A namedtuple is still a tuple, so it is immutable and hashable, and its hash comes from its field values. That works only as long as every field holds a hashable value: put a list or a dict in one of the fields and hash() raises TypeError, because tuples hash by hashing their contents. So keep the fields to strings, numbers, and other immutables if the record will be a key.
Q: You must resolve settings with precedence Command-Line Interface (CLI) arguments over environment variables over defaults. Why might ChainMap beat merging the dicts with the | operator?
ChainMap(cli, env, defaults) searches the maps in order and returns the first hit, which encodes the precedence directly. It is also a live view: if the underlying env dict changes later, the ChainMap sees the change immediately, while defaults | env | cli produces a frozen merged copy at that moment. One caveat to mention in an interview: writes through a ChainMap always go to the first map only.
Go deeper: Python collections documentation covers every edge case of this topic.
Related Posts
Previous: Python: Regex in Practice (Validation, Parsing, Text Processing)
Next: Python: Datetime and Time (Dates, Timezones, Formatting)
Series Home: Python + AI/ML Tutorial Series

No comment