Python: Collections Module (Counter, defaultdict, deque, namedtuple)

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.

Chaining and MappingChainMapStack multiple dictsas single lookupDouble-Ended QueuedequeO(1) append/pop both endsappendleft(), rotate(),maxlenOrdered and NamedOrderedDictRemembers insertion ordermove_to_end(), popitem()namedtupleTuple with named fields_replace(), _asdict()Default ValuesdefaultdictAuto-creates missing keysdefaultdict(list),defaultdict(int)Counting and FrequencyCounterCounts hashable objectsmost_common(), subtract()collections moduleWhen the built-indict, list, tuplearen’t quite enoughPython collections Module: Counter, defaultdict, deque, namedtuple, and When to Use Each

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.

Quick Reference

TypeReplacesKey FeatureUse When
CounterManual counting with dictCounts hashable objectsWord frequency, voting, histogram
defaultdictdict.setdefault() / if-key checksAuto-creates missing keysGrouping, accumulating, nested dicts
dequelist (slow left ops)O(1) append/pop both endsQueues, sliding windows, breadth-first search (BFS)
namedtupleTuples with index accessNamed fields, immutableLightweight records, comma-separated values (CSV) rows
OrderedDictdict (mostly)Reordering methodsLeast recently used (LRU) caches, ordered serialization
ChainMapMultiple dict lookupsStacks dicts as single viewConfig 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

NeedUseNOT
Count occurrencesCounterdefaultdict(int) (works but Counter is richer)
Group items by keydefaultdict(list)Manual if-key-exists check
Queue / BFSdequelist (O(n) popleft)
Fixed-size sliding windowdeque(maxlen=n)Manual list trimming
Immutable recordnamedtuplePlain tuple with index access
Mutable record with defaultsdataclass (covered in the dataclasses tutorial)namedtuple (immutable)
Config layer stackingChainMapMerging 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.

Previous: Python: Regex in Practice (Validation, Parsing, Text Processing)

Next: Python: Datetime and Time (Dates, Timezones, Formatting)

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *