Senior 11 min · March 05, 2026

Python collections — Counter Drops Zero-Count Keys

Counter subtraction silently discards zero-count keys, masking production alerts.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Counter: dict subclass for tallying any iterable; provides most_common(), arithmetic merging, and returns 0 for missing keys — but subtraction silently drops zero and negative counts.
  • defaultdict: dict that auto-creates values on missing key access using a callable; perfect for grouping and counting without boilerplate — never use bracket notation to check existence.
  • deque: double-ended queue with O(1) appends/pops from both ends; ideal for queues, stacks, and rolling windows with maxlen — not for random access by index.
  • namedtuple: tuple subclass with named fields; zero-overhead immutable records that support attribute access and tuple unpacking — _asdict() returns a plain dict in Python 3.8+.
  • OrderedDict: dict that remembers insertion order and offers move_to_end() for reordering — plain dict preserves order since 3.7 but cannot reorder.
  • ChainMap: logical merge of multiple dicts without copying; lookups search layered order, writes only hit the first mapping — great for config stacks (CLI > env > defaults).
Plain-English First

Imagine you are organising a school trip. A regular backpack works fine for most things, but sometimes you need specialised kit. You need a tally sheet that counts itself (Counter), a bag that automatically creates a new pocket the moment you stuff something new into it (defaultdict), a queue rope that lets you jump to the front or back in an instant (deque), a luggage tag that names every compartment so nobody confuses the first-aid kit with the snack bag (namedtuple), and a master itinerary that layers teacher rules over student preferences over school defaults — without rewriting the whole document (ChainMap). The collections module is exactly that set of specialised containers, each solving one specific friction point that plain lists and dicts handle awkwardly.

Every Python developer hits the same wall eventually. You are writing a word-frequency counter and you keep asking 'does this key exist yet?' before incrementing it. You are modelling a playing card and passing around a plain tuple, quietly hoping nobody accesses index 0 when they meant index 1. You are building a task processor and discovering, two weeks too late, that list.pop(0) is O(n) and your queue is now a bottleneck. These are not exotic edge cases — they are the everyday friction points that the collections module was designed to eliminate, and it has been shipping with Python since version 2.4.

The module solves a specific class of problem: data-wrangling tasks that are just awkward enough with built-in types to force you into boilerplate, but not complex enough to justify a third-party library. Instead of writing a three-line 'if key not in dict' guard every time you want a default value, defaultdict handles it in zero extra lines. Instead of sorting a list of tuples and trying to remember which index means 'price', namedtuple gives every field a readable name. The module trades a small learning investment for a measurable reduction in repetitive, error-prone code.

The module provides nine types in total. This article gives you deep coverage of the six you will reach for most often in production code — Counter, defaultdict, namedtuple, deque, OrderedDict, and ChainMap. The remaining three — UserDict, UserList, and UserString — are base classes designed for safely subclassing Python's built-in dict, list, and str types. They exist because inheriting directly from dict or list can produce subtle method-override bugs; the User variants route all operations through a single internal data store so your overrides always fire correctly. They are worth knowing about, but they solve a library-author problem rather than an everyday application problem, so they sit outside the scope of this article.

By the end you will know exactly which collection to reach for when you are counting things, building queues, working with structured data, or layering configuration. You will understand the performance trade-offs, the traps that catch experienced engineers, and how to talk about these types confidently in a technical interview.

Counter — Count Anything in One Line

Counter is a dict subclass built for one job: tallying. Hand it any iterable — a string, a list, a generator of log lines — and it returns a dict-like object where each key is an element and each value is how many times that element appeared. No initialisation loop, no conditional increment, no boilerplate of any kind.

Beyond raw counting, Counter ships with helper methods that make it genuinely composable in real pipelines. most_common(n) returns the n highest-frequency elements sorted descending — ideal for building leaderboards or word-cloud datasets. You can add two Counters together with + to merge tallies from separate data sources, making it trivial to combine weekly batch results without a manual merge loop.

The right mental model is the multiset, also called a bag. A bag is like a set that remembers multiplicity — it tracks not just what exists, but how many copies of each thing exist. When you need to know not just whether something is present but how many times it appears, Counter is your type. Real-world examples include analysing HTTP access logs to find the most-requested endpoints, scoring a Scrabble hand by letter frequency, or detecting duplicate records in a data pipeline.

One behaviour that consistently catches engineers: Counter arithmetic silently drops keys whose result is zero or negative. This is deliberate — a multiset with zero copies of an item simply does not contain that item. But when you are comparing two frequency distributions and you need to know which keys went to zero, that silent removal will bite you. The production incident section covers exactly this failure. The safe habit is to verify your expected keys still exist after any Counter arithmetic before passing results downstream.

Counter also returns 0 for missing keys rather than raising KeyError. This is enormously convenient for frequency checks — you can ask 'how many times did this word appear?' without guarding against the word being absent entirely.

word_frequency_counter.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from collections import Counter

# Analysing customer support feedback — a realistic text-processing pipeline
feedback_words = [
    "slow", "buggy", "slow", "great", "slow",
    "buggy", "excellent", "great", "slow", "excellent"
]

# Counter tallies every element automatically — no manual dict initialisation needed
word_tally = Counter(feedback_words)
print("Full tally:", word_tally)
# Output: Counter({'slow': 4, 'buggy': 2, 'great': 2, 'excellent': 2})

# most_common gives you a ranked list — top 3 complaints surfaced instantly
top_issues = word_tally.most_common(3)
print("Top 3 words:", top_issues)
# Output: [('slow', 4), ('buggy', 2), ('great', 2)]

# Counters support + arithmetic to merge two batches of feedback
week2_feedback = Counter(["slow", "excellent", "excellent", "buggy"])
combined = word_tally + week2_feedback
print("Combined over 2 weeks:", combined)
# Output: Counter({'slow': 5, 'excellent': 4, 'buggy': 3, 'great': 2})

# Missing keys return 0 — no KeyError, no guard clause needed
print("Count of 'terrible':", word_tally["terrible"])
# Output: Count of 'terrible': 0

# Subtraction — this is where engineers get caught out
# Counter drops any key whose result is zero or negative
difference = word_tally - week2_feedback
print("Difference (keys with zero result are gone):", difference)
# Output: Counter({'slow': 3, 'great': 2})
# Notice 'buggy' and 'excellent' are completely absent — their counts cancelled out
# This is intentional multiset behaviour, but silent enough to cause real bugs

# Safe comparison when zero-count keys matter:
# Use a plain dict and subtract manually
safe_diff = {
    key: word_tally.get(key, 0) - week2_feedback.get(key, 0)
    for key in word_tally
}
print("Safe difference (zero counts preserved):", safe_diff)
# Output: {'slow': 3, 'buggy': 0, 'great': 2, 'excellent': 0}
Output
Full tally: Counter({'slow': 4, 'buggy': 2, 'great': 2, 'excellent': 2})
Top 3 words: [('slow', 4), ('buggy', 2), ('great', 2)]
Combined over 2 weeks: Counter({'slow': 5, 'excellent': 4, 'buggy': 3, 'great': 2})
Count of 'terrible': 0
Difference (keys with zero result are gone): Counter({'slow': 3, 'great': 2})
Safe difference (zero counts preserved): {'slow': 3, 'buggy': 0, 'great': 2, 'excellent': 0}
Pro Tip:
Counter(string) counts individual characters — Counter('mississippi') gives you {'s': 4, 'i': 4, 'p': 2, 'm': 1} instantly. This is a common sub-problem inside larger coding challenges and interview questions. Knowing this saves you from writing a manual character-counting loop, which is exactly what interviewers are watching to see if you avoid.
Production Insight
Counter arithmetic silently drops keys with non-positive counts after subtraction.
Keys that go to zero or negative simply vanish from the result — no warning, no error.
This has caused real monitoring gaps where 'no key present' was misread as 'no problem'.
Always verify your expected keys still exist after arithmetic, or use a plain dict subtraction when zero-count keys carry meaning in your domain.
Key Takeaway
Counter eliminates manual frequency-counting boilerplate and adds most_common() and arithmetic merging.
Missing keys return 0 — never a KeyError.
But subtraction silently drops zero-count keys — use plain dict subtraction when those keys must survive.

defaultdict — Stop Writing 'if key not in dict' Forever

A defaultdict is a dict that automatically creates a value for a key the moment you first access it. You supply a callable — int, list, set, or any zero-argument function — when you construct it. That callable is invoked to produce the default value whenever a missing key is accessed. No KeyError, no setdefault gymnastics, no conditional guard.

The canonical use-case is grouping. You have a list of (student, subject) pairs and you want a dict that maps each student to a list of their subjects. With a plain dict you write three lines per insertion: check if the key exists, create an empty list if not, then append. With defaultdict(list) you just append — the empty list materialises automatically on first access.

Under the hood, defaultdict overrides __missing__, which is the method dict calls when a key lookup fails. This means defaultdict behaves identically to a regular dict in every other way — you can iterate it, pass it anywhere a dict is expected, and convert it with dict() for JSON serialisation. The only difference is that absent-key access never raises; it constructs.

One detail that trips up engineers: the default_factory callable is evaluated at __missing__ time, not at construction time. This means every missing key gets its own fresh default value — two different missing keys each get their own independent empty list, not references to the same list. That is the correct behaviour, and it is why defaultdict is safe for grouping.

For nested defaultdicts, you need to pass a callable that itself returns a defaultdict. The pattern is:

nested = defaultdict(lambda: defaultdict(list))

This works because the lambda is called fresh for each new outer key, producing a brand-new inner defaultdict(list) each time. The common mistake is writing defaultdict(defaultdict(list)) — this evaluates defaultdict(list) immediately, producing a single shared instance, and then passes that one instance as the factory. Every outer key ends up pointing to the same inner dict, causing all groups to bleed into each other. The factory must be a callable that returns a new object each time, not an object itself.

student_subject_grouper.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from collections import defaultdict

# Raw enrolment data — each tuple is (student_name, subject)
enrolments = [
    ("Alice", "Maths"),
    ("Bob", "Science"),
    ("Alice", "Science"),
    ("Charlie", "Maths"),
    ("Bob", "History"),
    ("Alice", "History"),
]

# defaultdict(list) creates an empty list automatically for any new key
student_subjects = defaultdict(list)

for student, subject in enrolments:
    # No 'if student not in student_subjects' guard needed — it just works
    student_subjects[student].append(subject)

print("Student subjects:", dict(student_subjects))
# Output: {'Alice': ['Maths', 'Science', 'History'], 'Bob': ['Science', 'History'], 'Charlie': ['Maths']}

# defaultdict(int) is perfect for manual counting when Counter is too heavy
vote_counts = defaultdict(int)
votes = ["Alice", "Bob", "Alice", "Charlie", "Alice", "Bob"]
for candidate in votes:
    vote_counts[candidate] += 1  # int() returns 0, so += 1 works on first access

print("Vote counts:", dict(vote_counts))
# Output: {'Alice': 3, 'Bob': 2, 'Charlie': 1}

# defaultdict(set) builds unique-value groups without any dedup logic
page_visitors = defaultdict(set)
visits = [("home", "user_1"), ("home", "user_2"), ("home", "user_1"), ("about", "user_1")]
for page, user in visits:
    page_visitors[page].add(user)  # set deduplicates automatically

print("Unique visitors per page:", dict(page_visitors))
# Output: {'home': {'user_1', 'user_2'}, 'about': {'user_1'}}

# --- The nested defaultdict pattern ---
# Correct: lambda returns a fresh defaultdict(list) for each new outer key
course_grades = defaultdict(lambda: defaultdict(list))
course_grades["Maths"]["Alice"].append(92)
course_grades["Maths"]["Bob"].append(85)
course_grades["Science"]["Alice"].append(88)
print("Course grades:", dict(course_grades["Maths"]))
# Output: {'Alice': [92], 'Bob': [85]}

# Wrong pattern — do NOT do this:
# broken = defaultdict(defaultdict(list))
# Every outer key would share the SAME inner defaultdict instance
# Appending to broken['Maths']['Alice'] would also affect broken['Science']['Alice']
Output
Student subjects: {'Alice': ['Maths', 'Science', 'History'], 'Bob': ['Science', 'History'], 'Charlie': ['Maths']}
Vote counts: {'Alice': 3, 'Bob': 2, 'Charlie': 1}
Unique visitors per page: {'home': {'user_1', 'user_2'}, 'about': {'user_1'}}
Course grades: {'Alice': [92], 'Bob': [85]}
Watch Out:
Accessing a missing key in a defaultdict with bracket notation creates that key immediately with the default value. This means len(your_defaultdict) grows every time you probe a non-existent key. Use 'key in your_defaultdict' for existence checks — the 'in' operator does not trigger __missing__ and never creates phantom entries. This is one of the most common defaultdict bugs in real codebases.
Production Insight
defaultdict can cause data corruption in multi-threaded code.
The default_factory call inside __missing__ is not atomic — in CPython the GIL provides some protection for individual operations, but this is an implementation detail, not a language guarantee, and it does not hold in free-threaded Python (3.13+), PyPy, or Jython.
If two threads race on the same missing key, you may get two factory calls and lose one write.
Use a threading.Lock around each critical section, or switch to a regular dict with explicit locking if thread safety is a hard requirement.
Key Takeaway
defaultdict auto-creates missing keys on first access using a callable factory.
Use for grouping and counting without if-key-exists guards.
Never use bracket notation to check existence — use 'in' instead or you will silently grow the dict.

namedtuple — Give Your Tuples a Memory

A plain tuple is positional amnesia. (52.3, -1.8) means nothing until you remember whether index 0 is latitude or longitude. namedtuple fixes this by generating a tuple subclass at runtime where every position has a name. It is immutable like a tuple, memory-efficient like a tuple, but readable like an object.

The magic is that namedtuple generates a real class — complete with __repr__, __eq__, and field access by attribute name. You get all the benefits of a lightweight data class without writing a single method. This is why namedtuple shows up throughout the standard library: os.stat_result, sys.version_info, and socket.getaddrinfo all return namedtuples or namedtuple-like objects.

The right mental model: namedtuple sits neatly between raw tuples (too opaque) and full classes (too heavy). It is the right choice when your data is immutable, has a fixed number of fields, and you want readable field access without defining a class. When you need mutability, default values, or methods, reach for dataclasses instead.

A few details worth knowing before you ship code with namedtuple. First, _asdict() returns a plain dict in Python 3.8 and later — it returned an OrderedDict in earlier versions, so if you have old code or documentation that says OrderedDict, update it. Second, _replace() creates a full copy of the tuple, which is the correct behaviour for an immutable type but becomes expensive when you are calling it millions of times inside a tight loop — at that scale, a list of plain dicts or a pandas DataFrame is a better fit. Third, default values are supported via the defaults parameter from Python 3.6.1 onwards, but the older workaround of using the class-based typing.NamedTuple form reads more cleanly when you have many fields with defaults.

product_catalogue.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from collections import namedtuple

# Define the structure once — this generates a real class called 'Product'
Product = namedtuple('Product', ['name', 'price', 'stock', 'category'])

# Instantiate like a class — no index guessing, no positional amnesia
laptop = Product(name="ProBook 450", price=899.99, stock=12, category="Electronics")
headphones = Product(name="SoundWave Pro", price=149.99, stock=35, category="Audio")
desk = Product(name="Standing Desk", price=399.00, stock=5, category="Furniture")

# Access fields by name — code reads like a sentence
print(f"{laptop.name} costs £{laptop.price} and has {laptop.stock} units in stock.")
# Output: ProBook 450 costs £899.99 and has 12 units in stock.

# Still a tuple underneath — indexing and unpacking both work
print("Price via index:", laptop[1])
# Output: Price via index: 899.99

# _replace creates a new instance with one field changed (immutability is preserved)
updated_laptop = laptop._replace(stock=10)
print("Updated stock:", updated_laptop.stock)
# Output: Updated stock: 10

# Sorts, filters, and list comprehensions all work naturally
catalogue = [laptop, headphones, desk]
by_price = sorted(catalogue, key=lambda product: product.price)
for item in by_price:
    print(f"{item.name}: £{item.price}")
# Output:
# SoundWave Pro: £149.99
# Standing Desk: £399.0
# ProBook 450: £899.99

# _asdict() returns a plain dict in Python 3.8+ (not an OrderedDict as in earlier versions)
print(type(laptop._asdict()))  # <class 'dict'>
print(laptop._asdict())
# Output: {'name': 'ProBook 450', 'price': 899.99, 'stock': 12, 'category': 'Electronics'}

# namedtuple with defaults (Python 3.6.1+)
# Last N field names get the default values from left to right
ProductWithDefault = namedtuple(
    'ProductWithDefault',
    ['name', 'price', 'stock', 'category'],
    defaults=['Uncategorised']  # only 'category' gets a default
)
backup_drive = ProductWithDefault(name="BackupDrive 2TB", price=79.99, stock=50)
print(backup_drive.category)
# Output: Uncategorised
Output
ProBook 450 costs £899.99 and has 12 units in stock.
Price via index: 899.99
Updated stock: 10
SoundWave Pro: £149.99
Standing Desk: £399.0
ProBook 450: £899.99
<class 'dict'>
{'name': 'ProBook 450', 'price': 899.99, 'stock': 12, 'category': 'Electronics'}
Uncategorised
Interview Gold:
Interviewers regularly ask 'what is the difference between namedtuple and dataclass?' The clean answer: namedtuple is immutable, tuple-compatible, and has zero memory overhead beyond the underlying tuple — ideal for read-only records. dataclass is mutable by default, supports default values naturally, can have methods, and supports __slots__ for memory optimisation. Use namedtuple for simple, stable, immutable data bags. Use dataclass for anything with behaviour, optional fields, or mutation. Both are correct choices depending on the constraints; knowing when each fits is what interviewers are actually testing.
Production Insight
namedtuple fields are stored as tuple slots, so each instance consumes only as much memory as the equivalent plain tuple — no per-instance __dict__ overhead.
However, _replace() constructs a full copy of the tuple on every call.
At scale — say, updating one field across a million records in a loop — the copy cost adds up quickly.
If your workload involves frequent per-field updates, a list of plain dicts or a dataclass with __slots__ will outperform namedtuple significantly.
Profile before committing namedtuple to a hot path that mutates records.
Key Takeaway
namedtuple adds named field access to tuples with zero memory overhead versus a plain tuple.
Ideal for immutable records such as database rows or API response entries.
_asdict() returns a plain dict in Python 3.8+ — update any code or docs that expect an OrderedDict.

deque — The Double-Ended Queue That Outperforms Lists

A Python list is secretly bad at one thing: inserting or removing elements from the front. list.pop(0) and list.insert(0, item) are O(n) operations because Python has to shift every other element in memory after the removal point. For small lists you will never notice. For a queue processing thousands of events per second, or a rolling window accumulating sensor data, it is a hidden bottleneck that appears gradually as your data grows.

deque (pronounced 'deck', short for double-ended queue) solves this with O(1) appends and pops from both ends. It is backed by a doubly-linked list of fixed-size blocks, so adding or removing from either end is always a pointer update — no shifting, no reallocation. Use deque any time your data structure is conceptually a queue (first-in, first-out), a stack (last-in, first-out), or a sliding window over the most recent N items.

The maxlen parameter is one of deque's most useful features. When set, the deque automatically discards items from the opposite end when it fills. This gives you a rolling window — a fixed-size buffer that always holds the most recent N items — in zero extra code. Think: last 100 log lines for a live tail view, last 10 sensor readings for an average calculation, or last 5 user actions for an undo buffer.

Deque does not support O(1) random access by index. Accessing deque[500] traverses nodes from the nearest end, making it O(n). This is not a bug or an oversight — it is an inherent property of the linked-block structure that gives you O(1) ends. If you need fast random access by index alongside fast ends, neither list nor deque is a perfect fit. In practice, most queue and window patterns access only the ends, so this is rarely a real constraint.

One more thing: thread safety. Individual append and popleft operations are atomic in CPython due to the GIL, but the GIL is increasingly opt-in rather than guaranteed — Python 3.13 introduced a free-threaded build mode. Compound operations such as 'check if non-empty, then popleft' are never atomic, regardless of implementation. For thread-safe producer-consumer patterns, use queue.Queue, which is designed for exactly that purpose.

task_queue_and_sliding_window.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
from collections import deque

# --- USE CASE 1: Efficient task queue ---
# Simulating a print job queue — FIFO with occasional priority bumps
print_queue = deque()

print_queue.append("Invoice_March.pdf")       # Normal priority — goes to the right
print_queue.append("Report_Q1.xlsx")
print_queue.appendleft("URGENT_Contract.pdf")  # High priority — jumps to the front

print("Queue state:", list(print_queue))
# Output: Queue state: ['URGENT_Contract.pdf', 'Invoice_March.pdf', 'Report_Q1.xlsx']

# Process jobs FIFO — popleft is O(1), list.pop(0) would be O(n)
while print_queue:
    job = print_queue.popleft()
    print(f"Printing: {job}")
# Output:
# Printing: URGENT_Contract.pdf
# Printing: Invoice_March.pdf
# Printing: Report_Q1.xlsx

print()

# --- USE CASE 2: Rolling window with maxlen ---
# Keeping a live feed of the last 4 server response times in milliseconds
response_times = deque(maxlen=4)  # Automatically discards the oldest when full

readings = [120, 135, 98, 210, 87, 310, 95]
for reading in readings:
    response_times.append(reading)
    avg = sum(response_times) / len(response_times)
    print(f"Added {reading}ms | Window: {list(response_times)} | Avg: {avg:.1f}ms")

# The window slides automatically — no manual eviction code needed

print()

# --- USE CASE 3: Why list.pop(0) hurts at scale ---
import timeit

# Build a list and a deque with 100,000 elements
test_list = list(range(100_000))
test_deque = deque(range(100_000))

list_time = timeit.timeit(lambda: test_list.insert(0, 0), number=1_000)
deque_time = timeit.timeit(lambda: test_deque.appendleft(0), number=1_000)

print(f"list.insert(0) for 1000 ops: {list_time:.4f}s")
print(f"deque.appendleft() for 1000 ops: {deque_time:.4f}s")
# deque is typically 10-100x faster for front insertions on large collections
Output
Queue state: ['URGENT_Contract.pdf', 'Invoice_March.pdf', 'Report_Q1.xlsx']
Printing: URGENT_Contract.pdf
Printing: Invoice_March.pdf
Printing: Report_Q1.xlsx
Added 120ms | Window: [120] | Avg: 120.0ms
Added 135ms | Window: [120, 135] | Avg: 127.5ms
Added 98ms | Window: [120, 135, 98] | Avg: 117.7ms
Added 210ms | Window: [120, 135, 98, 210] | Avg: 140.8ms
Added 87ms | Window: [135, 98, 210, 87] | Avg: 132.5ms
Added 310ms | Window: [98, 210, 87, 310] | Avg: 176.2ms
Added 95ms | Window: [210, 87, 310, 95] | Avg: 175.5ms
list.insert(0) for 1000 ops: 0.4823s
deque.appendleft() for 1000 ops: 0.0041s
Watch Out:
deque does not support O(1) random access by index. deque[500] is O(n) because the implementation must traverse nodes from the nearest end to reach that position. If you find yourself indexing a deque by position frequently, you have likely chosen the wrong type — use a list for the random-access path. Use deque specifically and exclusively when your access pattern is append and pop from the ends only.
Production Insight
Individual deque operations such as append and popleft are atomic in CPython, but this is a CPython implementation detail — not a Python language guarantee.
Free-threaded Python (3.13+ with the GIL disabled), PyPy, and Jython do not provide the same atomicity.
More importantly, compound operations such as 'check length, then popleft' are never atomic in any implementation.
For reliable producer-consumer workloads across threads, use queue.Queue, which provides blocking gets, timeouts, and join() semantics built in.
Key Takeaway
deque gives O(1) appends and pops from both ends — list.pop(0) is O(n) and will hurt you at scale.
Use maxlen for automatic rolling windows with zero eviction code.
Do not use deque for random access by index — that is what lists are for.

OrderedDict — Order Matters When Order Matters

Since Python 3.7, regular dicts preserve insertion order as a language guarantee — not an implementation detail. So you might reasonably ask: why does OrderedDict still exist? The answer is two specific capabilities a plain dict will never have: move_to_end() for reordering entries in place, and order-sensitive equality where two OrderedDicts are considered equal only if they contain the same keys in the same sequence.

move_to_end(key, last=True) moves an existing key to the end of the dictionary — or to the front if you pass last=False. This single method makes OrderedDict the natural foundation for an LRU cache. You maintain a fixed-capacity OrderedDict: on every cache hit, move the accessed key to the end (most recently used). When the cache is full and a new key arrives, pop the first item (least recently used) with popitem(last=False). The whole mechanism fits in about fifteen lines without any additional data structures.

Order-sensitive equality is the other differentiator. Two regular dicts with the same keys and values are always equal regardless of insertion order. Two OrderedDicts are equal only if the key order also matches. This matters when you are testing serialised output — for example, verifying that a configuration dict round-trips through JSON in a specific field order, or asserting that a pipeline produces results in a defined sequence.

The cost is memory. OrderedDict maintains an internal doubly-linked list to track ordering, which roughly doubles its memory footprint versus a plain dict. For small collections this is irrelevant. For millions of keys it is significant. If you do not need move_to_end() or order-sensitive equality, a plain dict is strictly better — both faster and lighter. That is the practical decision rule: start with a plain dict, upgrade to OrderedDict only when you reach for one of those two specific features.

ordered_cache.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from collections import OrderedDict

class LRUCache:
    """Fixed-capacity cache that evicts the least recently used item."""

    def __init__(self, capacity: int):
        self.cache = OrderedDict()
        self.capacity = capacity

    def get(self, key):
        if key not in self.cache:
            return -1
        # Move to end — this marks the key as most recently used
        self.cache.move_to_end(key)
        return self.cache[key]

    def put(self, key, value):
        if key in self.cache:
            # Refresh position of existing key
            self.cache.move_to_end(key)
        elif len(self.cache) >= self.capacity:
            # Evict the oldest entry — the first item in the OrderedDict
            evicted_key, _ = self.cache.popitem(last=False)
            print(f"  [evict] '{evicted_key}' removed (LRU)")
        self.cache[key] = value

# Simulate a cache with capacity 3
cache = LRUCache(capacity=3)

print("PUT A=1"); cache.put('A', 1); print(" State:", list(cache.cache.items()))
print("PUT B=2"); cache.put('B', 2); print(" State:", list(cache.cache.items()))
print("PUT C=3"); cache.put('C', 3); print(" State:", list(cache.cache.items()))
print("GET A  ->", cache.get('A'));  print(" State:", list(cache.cache.items()))
print("GET B  ->", cache.get('B'));  print(" State:", list(cache.cache.items()))
print("PUT D=4"); cache.put('D', 4); print(" State:", list(cache.cache.items()))
print("GET C  ->", cache.get('C'));  print(" State:", list(cache.cache.items()))
print("GET D  ->", cache.get('D'));  print(" State:", list(cache.cache.items()))

# Demonstrating order-sensitive equality — the plain dict difference
from collections import OrderedDict
ord1 = OrderedDict([('x', 1), ('y', 2)])
ord2 = OrderedDict([('y', 2), ('x', 1)])
plain1 = {'x': 1, 'y': 2}
plain2 = {'y': 2, 'x': 1}

print("\nOrderedDict equal (same order)?  ", ord1 == ord2)   # False — order differs
print("OrderedDict equal (diff order)?  ", ord1 == ord2)   # False
print("plain dict equal (any order)?    ", plain1 == plain2) # True — order ignored
Output
PUT A=1
State: [('A', 1)]
PUT B=2
State: [('A', 1), ('B', 2)]
PUT C=3
State: [('A', 1), ('B', 2), ('C', 3)]
GET A -> 1
State: [('B', 2), ('C', 3), ('A', 1)]
GET B -> 2
State: [('C', 3), ('A', 1), ('B', 2)]
PUT D=4
[evict] 'C' removed (LRU)
State: [('A', 1), ('B', 2), ('D', 4)]
GET C -> -1
State: [('A', 1), ('B', 2), ('D', 4)]
GET D -> 4
State: [('A', 1), ('B', 2), ('D', 4)]
OrderedDict equal (same order)? False
OrderedDict equal (diff order)? False
plain dict equal (any order)? True
When to Use:
Reach for OrderedDict in exactly two situations: when you need move_to_end() to reorder entries in place (LRU caches being the textbook case), or when you need order-sensitive equality for testing and verification. Every other insertion-order use case is handled by a plain dict in Python 3.7+ at lower memory cost and higher speed.
Production Insight
OrderedDict uses a doubly-linked list internally for order tracking, consuming roughly twice the memory of a plain dict at the same key count.
For large datasets — hundreds of thousands of keys or more — this overhead is measurable and can push your process into memory pressure territory.
Profile memory before committing OrderedDict to a long-lived cache or registry. If you do not need move_to_end() or order-sensitive equality, swap to a plain dict — you will reclaim the overhead for free.
Key Takeaway
OrderedDict preserves insertion order and adds move_to_end() — the plain dict does not.
Use it for LRU caches and order-sensitive equality checks.
Everything else: use a plain dict. It is faster and uses half the memory.

ChainMap — Layer Multiple Dictionaries Without Merging

ChainMap takes multiple dictionaries and presents them as a single logical mapping without copying any data. Lookups search each dict in the order you supplied until the key is found; writes modify only the first dict. This is precisely the pattern you need for layered configuration: command-line flags override environment variables, which override config file settings, which override hard-coded defaults — and you want all four layers to remain independent so each can be updated without touching the others.

The memory efficiency comes from holding references to the original dicts rather than creating copies. When you update one of the underlying dicts, ChainMap reflects the change immediately on the next lookup. This makes it correct for runtime-configurable systems where overrides may be temporary — for example, a request-scoped context that adds per-request flags on top of global application settings, then discards the override when the request ends.

new_child() adds a new empty dict to the front of the chain and returns a new ChainMap — leaving the original untouched. This is useful for scoped overrides: create a child context, let it shadow the parent for the duration of a block, then discard it. parents returns the rest of the chain without the first mapping, which is how you walk up through the layers.

Two behaviours are worth knowing precisely before you rely on ChainMap in production. First, writes. Setting a key through ChainMap always writes to the first mapping — even if that key already exists in a deeper layer. If you want to update a key in a specific deeper layer, you must directly access that dict. This is intentional and correct for the override model, but it surprises engineers who expect assignment to update the key wherever it lives. Second, len() and keys(). ChainMap.len() and ChainMap.keys() count unique keys across all mappings — a key shadowed by an earlier layer is counted once, not multiple times. If you are trying to count all occurrences of a key across every layer, you need to iterate through each underlying dict manually.

layered_config.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from collections import ChainMap

# Three configuration layers, each a plain dict — no copies, no merging
defaults = {
    'host': 'localhost',
    'port': 5432,
    'dbname': 'app',
    'debug': False,
    'timeout': 30,
}

environment = {
    'host': 'prod-db.example.com',
    'debug': True,   # env enables debug mode
}

# Parsed from sys.argv in a real application
cli_overrides = {
    'port': 15432,
}

# Priority: CLI > environment > defaults
config = ChainMap(cli_overrides, environment, defaults)

# Lookups search from left to right — first match wins
print(f"host   : {config['host']}")     # from environment
print(f"port   : {config['port']}")     # from CLI
print(f"debug  : {config['debug']}")    # from environment
print(f"dbname : {config['dbname']}")   # from defaults
print(f"timeout: {config['timeout']}")  # from defaults

print()

# Writes go to the first mapping only
config['port'] = 5433
print(f"After write — CLI dict port : {cli_overrides['port']}")   # 5433
print(f"After write — env dict port : {environment.get('port', 'not set')}")
print(f"After write — defaults port : {defaults['port']}")
# Only cli_overrides was modified — the other dicts are unchanged

print()

# len() and keys() count unique keys across all layers
print("Unique keys:", list(config.keys()))
print("Total unique key count:", len(config))
# 'host', 'debug' appear in multiple layers but are counted once each

print()

# new_child() adds a temporary override layer — original chain is untouched
request_config = config.new_child({'timeout': 5, 'request_id': 'req_abc123'})
print(f"Request timeout  : {request_config['timeout']}")     # 5 (overridden)
print(f"Global timeout   : {config['timeout']}")             # 30 (unchanged)
print(f"Request host     : {request_config['host']}")        # from environment via parent chain

# parents gives you the chain without the child layer
print(f"Parent chain timeout: {request_config.parents['timeout']}")  # 30
Output
host : prod-db.example.com
port : 15432
debug : True
dbname : app
timeout: 30
After write — CLI dict port : 5433
After write — env dict port : not set
After write — defaults port : 5432
Unique keys: ['port', 'host', 'debug', 'dbname', 'timeout']
Total unique key count: 5
Request timeout : 5
Global timeout : 30
Request host : prod-db.example.com
Parent chain timeout: 30
Pro Tip:
ChainMap.new_child() is the cleanest way to implement scoped configuration overrides without mutating the parent chain. In a web framework, you might create a child ChainMap per request with per-request headers or feature flags layered on top of global settings, then let the child go out of scope when the request completes. No teardown, no cleanup — the parent chain was never touched.
Production Insight
ChainMap lookups are O(number of maps) in the worst case — the lookup walks each dict in sequence until the key is found or all maps are exhausted.
For a three-layer config this is effectively O(1) in practice.
But if you are building a ChainMap with dozens of layers and performing millions of lookups per second, the sequential search cost adds up.
In that scenario, merge the layers into a single dict once at startup and use ChainMap only during the assembly phase — you get the layering semantics for free during construction and O(1) lookup at runtime.
Key Takeaway
ChainMap layers multiple dicts logically without copying any data.
Lookups search left to right; writes only affect the first mapping.
Use new_child() for scoped temporary overrides — the parent chain is never modified.
● Production incidentPOST-MORTEMseverity: high

Counter Subtraction Removes Zero-Count Keys — A Silent Data Loss

Symptom
A monitoring pipeline subtracted yesterday's HTTP status code counts from today's to find new errors. The resulting Counter showed no key for '500' even though 500 errors had occurred — they were masked because today's count was lower than yesterday's, producing a zero or negative result that Counter silently discarded.
Assumption
Counter subtraction preserves all keys with their resulting counts, including zeros and negatives, the same way a regular dict subtraction would.
Root cause
Counter arithmetic silently drops keys that end up with a count of zero or negative. This is intentional design — Counter models a multiset where zero means absence — but it is a trap when you rely on the presence of zero-count keys for downstream alerting or processing. The behaviour is documented but easy to miss, because it contradicts how engineers instinctively expect subtraction to behave.
Fix
Use a defaultdict(int) for manual subtraction if you need all keys preserved regardless of result. Alternatively, subtract the underlying dicts directly and filter afterwards. Do not rely on Counter arithmetic when zero-count keys carry meaning in your domain.
Key lesson
  • Counter subtraction does not preserve zero or negative counts — those keys disappear from the result entirely.
  • Counter intersection (&) also drops zero-count results — it takes the minimum of matching positive counts, not a floor-at-zero minimum.
  • If you need to track absent or zero values, use a regular dict or defaultdict(int) and subtract manually.
  • Always verify that expected keys still exist after any arithmetic operation on Counters before passing results to downstream systems.
Production debug guideSymptom → Action guide for common collections pitfalls6 entries
Symptom · 01
defaultdict unexpectedly contains keys you never explicitly inserted
Fix
You used bracket notation to check existence. Replace 'if my_defaultdict[key]' with 'if key in my_defaultdict' — the 'in' operator does not trigger __missing__ and will never create a phantom key.
Symptom · 02
Counter arithmetic drops keys you expected to see in the result
Fix
Counter subtraction and intersection silently discard zero and negative counts by design. Switch to manual dict subtraction with a defaultdict(int) if you need all keys preserved in the output.
Symptom · 03
deque operations become slower as the data structure grows
Fix
You are likely accessing deque by index position. deque is O(1) for both ends but O(n) for random access because it traverses nodes. If you need positional access alongside fast ends, use a list for the random-access path and a deque only where you exclusively append and pop from the ends.
Symptom · 04
OrderedDict is consuming noticeably more memory than a plain dict
Fix
OrderedDict maintains an internal doubly-linked list for order tracking, roughly doubling its memory footprint versus a plain dict. If you do not need move_to_end() or order-sensitive equality checks, replace it with a plain dict — Python 3.7+ dicts preserve insertion order natively at no extra cost.
Symptom · 05
ChainMap writes are not propagating to the layer you expected
Fix
ChainMap writes always modify only the first mapping in the chain, regardless of where the key originally lives. To update a key in a deeper layer, access that specific dict directly and modify it there.
Symptom · 06
len() on a ChainMap returns a smaller number than expected
Fix
ChainMap.len() counts unique keys across all mappings — a key that appears in multiple layers is counted once. If your number looks wrong, print list(chain_map.keys()) to see the full deduplicated key set, and inspect each underlying dict separately to verify per-layer counts.
★ Quick Debug Cheat Sheet: Python CollectionsOne-liners to diagnose common collections misbehaviour in production
Phantom keys in defaultdict
Immediate action
Use 'key in my_defaultdict' to check existence — bracket access creates the key if it is absent.
Commands
if key in my_defaultdict: print('exists')
print(len(my_defaultdict)) # run before and after suspect code to see if it grew
Fix now
Replace all bracket-notation existence checks with 'in' operator. If the phantom keys are already there, rebuild the defaultdict from a filtered copy.
Counter missing key after subtraction+
Immediate action
Counter drops keys whose count hits zero or goes negative — this is by design, not a bug.
Commands
manual = {k: counter1.get(k, 0) - counter2.get(k, 0) for k in counter1} # preserves zeros
print(counter1 - counter2) # compare to see what Counter silently discarded
Fix now
Use a defaultdict(int) and subtract manually if zero-count keys must survive in your output.
namedtuple._replace() is slow on large datasets+
Immediate action
Profile first — _replace() creates a full copy of the tuple, which is cheap for small records but adds up across millions of items.
Commands
import timeit; timeit.timeit(lambda: item._replace(field=value), number=100_000)
print(item._asdict()) # check record size and field count before optimising
Fix now
Switch to a plain dict or a dataclass with __slots__ for records that mutate frequently at scale.
Collections Comparison
Collection TypeBest Used WhenKey Advantage Over Built-inMutability
CounterTallying frequencies, comparing distributions, merging counts from multiple sourcesmost_common(), arithmetic merging, returns 0 for missing keys instead of raising KeyErrorMutable
defaultdictGrouping items into lists or sets, accumulating counts without explicit initialisationAuto-creates missing keys on first access — eliminates if-key-exists guard clauses entirelyMutable
namedtupleImmutable records with a fixed set of named fields — database rows, API response entries, coordinate pairsField names instead of index numbers, zero memory overhead versus a plain tuple, full tuple compatibilityImmutable
dequeFIFO queues, LIFO stacks, rolling windows over the most recent N itemsO(1) append and pop from both ends — list.pop(0) is O(n) and degrades under loadMutable
OrderedDictLRU caches, order-sensitive equality checks, data structures that need in-place reorderingmove_to_end() and order-aware equality — neither is available on a plain dictMutable
ChainMapLayered configuration (CLI > env > config file > defaults), scoped request contextsLogical merge of multiple dicts without any copying — lookups and writes stay layeredMutable (first map only)

Key takeaways

1
Counter eliminates manual frequency-counting boilerplate and adds most_common() and arithmetic merging
reach for it any time you need to tally anything. But remember: subtraction silently drops zero-count keys, so verify expected keys survive arithmetic before passing results downstream.
2
defaultdict auto-creates missing keys on first access using a callable factory, making grouping patterns a single line. Never use bracket notation to check if a key exists or you will silently create phantom entries
always use the 'in' operator for existence checks.
3
namedtuple is a zero-overhead way to add field names to a tuple
ideal for immutable records like database rows or API response objects. _asdict() returns a plain dict in Python 3.8+, not an OrderedDict. Graduate to dataclass when you need mutability, default values, or methods.
4
deque is the correct type for any queue or sliding-window pattern
list.pop(0) is O(n) and degrades under load; deque.popleft() is always O(1). Use maxlen for automatic rolling windows. But deque is not a list replacement — random access by index is O(n) on a deque.
5
OrderedDict exists for two specific capabilities
move_to_end() for in-place reordering, and order-sensitive equality for testing. Plain dict handles insertion-order preservation in Python 3.7+ at lower memory cost. Start with plain dict; upgrade only when you reach for one of those two features.
6
ChainMap layers dicts logically without copying data
ideal for config stacks and scoped overrides. Writes always go to the first mapping only; to update a deeper layer, access that dict directly. len() and keys() count unique keys across all layers, not per-layer occurrences.

Common mistakes to avoid

6 patterns
×

Using list.pop(0) or list.insert(0, item) for a queue

Symptom
Code works correctly in testing but slows dramatically as data grows — O(n) per front operation shifts the entire list in memory on every call.
Fix
Replace your list with a deque and use popleft() and appendleft(). It is a one-line swap with identical semantics but O(1) performance regardless of size.
×

Probing a defaultdict with bracket notation to check whether a key exists

Symptom
The key now exists in the dict with its default value even though you only wanted to test for membership — len() grows unexpectedly, iterations include keys you never explicitly inserted.
Fix
Always use 'if key in my_defaultdict' for existence checks. Bracket notation is for getting-or-creating, not for inspecting. If the phantom keys are already there, rebuild the defaultdict from a filtered copy.
×

Misspelling a keyword argument name in namedtuple's _replace()

Symptom
TypeError: got an unexpected keyword argument — the error message names the misspelled argument but gives no hint about which field you intended. IDE autocomplete does not catch typos in string-literal field names used elsewhere in the code.
Fix
_replace() only accepts exact keyword argument names matching the tuple's field names. Use your IDE's attribute-access autocomplete on the rest of your code to catch misspellings early. If _replace() raises TypeError, the first thing to check is whether the argument name exactly matches one of the _fields.
×

Assuming OrderedDict is needed for insertion order in Python 3.7+

Symptom
Extra memory consumption with no functional benefit — OrderedDict uses a doubly-linked list internally that roughly doubles memory per key versus a plain dict.
Fix
Use a plain dict for insertion-order preservation. Upgrade to OrderedDict only when you specifically need move_to_end() or order-sensitive equality. If you are unsure, you almost certainly do not need OrderedDict.
×

Trying to update a key in a deeper ChainMap layer by writing through the ChainMap

Symptom
The write appears to succeed but only modifies the first mapping. The deeper layer still holds the old value, causing hard-to-trace inconsistencies when different code paths read from different layers.
Fix
To update a key in a specific layer, access that underlying dict directly and modify it there. ChainMap writes always go to the first mapping — that is by design, not a limitation to work around.
×

Using defaultdict(defaultdict(list)) for nested grouping

Symptom
All outer keys share the same inner defaultdict instance. Appending to nested_dict['group_a']['item'] also affects nested_dict['group_b']['item'] because both point to the same object.
Fix
Use defaultdict(lambda: defaultdict(list)) — the lambda is called fresh for each new outer key, producing an independent inner defaultdict every time. The factory must be a callable that returns a new object, not an object itself.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Why would you choose defaultdict over dict.setdefault()? What are the pe...
Q02SENIOR
Explain why deque has O(1) append and popleft while a list has O(n) for ...
Q03SENIOR
Counter is a subclass of dict. What specifically does it add, and what h...
Q04SENIOR
What is the difference between Python's dict and OrderedDict in Python 3...
Q05SENIOR
Explain how ChainMap works for configuration management. What are the pr...
Q01 of 05SENIOR

Why would you choose defaultdict over dict.setdefault()? What are the performance and readability differences?

ANSWER
The key difference is where the default value is specified and when it is evaluated. With setdefault(), you must pass the default value at every call site — and because Python evaluates all arguments eagerly before calling a function, the default expression is evaluated on every call regardless of whether the key exists. This means setdefault('key', []) evaluates [] every time, which is cheap for a list literal but expensive for something like a database query. defaultdict specifies the factory once at construction time and calls it only when a key is actually missing, not on every access. Readability also improves: the intent is declared once when the dict is created rather than scattered across every insertion site. The practical rule: use defaultdict when you have a uniform default type throughout the dict's lifetime; use setdefault() when the default value needs to vary per call or when you are working with a plain dict you did not construct yourself.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
When should I use Python's collections module instead of a regular dict or list?
02
Is defaultdict slower than a regular dict in Python?
03
What is the difference between collections.namedtuple and Python 3.7 dataclasses?
04
Can deque be used for multi-threaded task queues?
05
Is ChainMap slower than a single merged dict for lookups?
🔥

That's Python Libraries. Mark it forged?

11 min read · try the examples if you haven't

Previous
itertools Module in Python
12 / 51 · Python Libraries
Next
datetime Module in Python