Choosing the Right Container¶
What this lesson gives you
The collections module and dataclasses give you purpose-built structures. Picking the right one signals engineering maturity.
Estimated time: 20 min read · Part: Data Structures That Carry Real Work
Learning objectives
- Know the full
collectionstoolkit: Counter, defaultdict, deque, namedtuple, OrderedDict, ChainMap - Write
@dataclasses correctly, including safe mutable defaults withfield() - Use
heapqas a priority queue for scheduling and top-k problems - Apply the container selection decision framework to any problem
- Understand
array.arrayandmemoryviewfor memory-efficient numeric storage
Beyond the four core containers, the standard library's collections module offers specialized structures for common patterns. Reaching for the right one — instead of bolting everything onto a plain dict or list — signals the difference between someone who knows Python and someone who engineers with it. A Counter where you'd otherwise write three lines of counting logic; a defaultdict where you'd write a setdefault() check; a deque where you'd write costly pop(0) calls.
Counter: Frequency Analysis in One Line¶
Counter is a dict subclass purpose-built for counting. You construct it from any iterable and get a mapping of element → count. Its .most_common(n) method returns the top-n elements by frequency in O(n log k) time. Counter objects support arithmetic: adding two Counters sums their counts; subtracting removes counts (keeping only positives).
from collections import Counter
# Construct from any iterable
word_freq = Counter("the quick brown fox jumps over the lazy fox".split())
# Counter({'the': 2, 'fox': 2, 'quick': 1, ...})
word_freq.most_common(3) # [('the', 2), ('fox', 2), ('quick', 1)]
word_freq["the"] # 2
word_freq["missing"] # 0 — Counter never raises KeyError
# Arithmetic on Counters
a = Counter({"python": 3, "java": 2, "rust": 1})
b = Counter({"python": 1, "go": 4, "java": 1})
a + b # Counter({'go': 4, 'python': 4, 'java': 3, 'rust': 1})
a - b # Counter({'python': 2, 'rust': 1}) — positive only
a & b # Counter({'python': 1, 'java': 1}) — min of each
a | b # Counter({'go': 4, 'python': 3, 'java': 2, 'rust': 1}) — max
# Real-world: count HTTP status codes in access log
import re
log_lines = [
"GET /api/users 200", "POST /api/orders 201",
"GET /api/users 200", "DELETE /api/item 404",
"GET /api/orders 500", "GET /api/users 200",
]
status_codes = Counter(
m.group() for line in log_lines
if (m := re.search(r"\d{3}$", line))
)
print(status_codes) # Counter({'200': 3, '201': 1, '404': 1, '500': 1})
print(status_codes.most_common(2)) # [('200', 3), ('201', 1)]
Counter({'200': 3, '201': 1, '404': 1, '500': 1})
defaultdict and ChainMap: Auto-init and Layered Lookups¶
defaultdict takes a callable as its first argument. When a missing key is accessed, it calls that callable to produce a default and inserts it. This eliminates the boilerplate if key not in d: d[key] = [] pattern, making grouping and accumulation code much cleaner.
from collections import defaultdict, ChainMap
# Grouping: one-to-many mapping
events = [
("alice", "login"), ("bob", "view"), ("alice", "purchase"),
("carol", "login"), ("bob", "logout"), ("alice", "logout"),
]
by_user = defaultdict(list)
for user, action in events:
by_user[user].append(action)
# defaultdict(list, {'alice': ['login', 'purchase', 'logout'],
# 'bob': ['view', 'logout'],
# 'carol': ['login']})
# Nested grouping: region → category → list of amounts
sales = [
("EU", "hardware", 200), ("US", "software", 150),
("EU", "software", 80), ("US", "hardware", 320),
("EU", "hardware", 110),
]
grouped = defaultdict(lambda: defaultdict(list))
for region, cat, amount in sales:
grouped[region][cat].append(amount)
# {"EU": {"hardware": [200, 110], "software": [80]}, ...}
# Summing: accumulate totals without init check
revenue = defaultdict(float)
for region, _, amount in sales:
revenue[region] += amount
# defaultdict(float, {'EU': 390.0, 'US': 470.0})
# ChainMap: layered lookup (think: local scope → module scope → builtins)
defaults = {"color": "blue", "size": "M", "qty": 1}
user_prefs = {"size": "XL"}
session = {"qty": 3}
# First match wins — like variable scoping
config = ChainMap(session, user_prefs, defaults)
config["size"] # "XL" — from user_prefs
config["color"] # "blue" — fallback to defaults
config["qty"] # 3 — from session
dataclasses: Structured Records Without Boilerplate¶
@dataclass (Python 3.7+) generates __init__, __repr__, and __eq__ from class-level type annotations. It's the recommended way to define data-carrying objects — more expressive than a plain dict, less ceremonious than a full OOP class. The field() function handles mutable defaults, computed fields, and comparison exclusion.
from dataclasses import dataclass, field, KW_ONLY
from datetime import datetime
@dataclass
class LineItem:
sku: str
qty: int
price: float
@property
def subtotal(self) -> float:
return round(self.qty * self.price, 2)
@dataclass
class Order:
id: int
customer: str
items: list[LineItem] = field(default_factory=list) # safe mutable default
created_at: datetime = field(default_factory=datetime.utcnow)
_: KW_ONLY = field(init=False, repr=False) # forces rest to kw-only
paid: bool = False
def total(self) -> float:
return round(sum(i.subtotal for i in self.items), 2)
def add_item(self, sku: str, qty: int, price: float) -> None:
self.items.append(LineItem(sku, qty, price))
# Usage
order = Order(id=1, customer="Ada")
order.add_item("PEN-001", qty=3, price=2.50)
order.add_item("PAD-002", qty=1, price=12.99)
print(order.total()) # 20.49
print(order)
# Order(id=1, customer='Ada', items=[LineItem(...), LineItem(...)], paid=False)
# Frozen dataclass: immutable, hashable (can be a set member / dict key)
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(1.0, 2.0)
# p.x = 3.0 # FrozenInstanceError
{p: "origin"} # works because frozen=True makes it hashable
20.49 Order(id=1, customer='Ada', items=[LineItem(sku='PEN-001', qty=3, price=2.5), LineItem(sku='PAD-002', qty=1, price=12.99)], paid=False)
Never use a mutable default in @dataclass without field()
The exact same mutable-default-argument bug applies: items: list = [] as a dataclass field would be shared across all instances. Python 3.7+ detects this and raises ValueError: mutable default ... is not allowed. The fix is always field(default_factory=list) — the factory is called fresh for each instance. This is one of the most common dataclass mistakes when migrating from plain dicts.
heapq: Priority Queues and Top-K Problems¶
A min-heap is a binary tree stored in a list where every parent is smaller than its children. The standard library's heapq module gives O(log n) push/pop and O(n) heapify. Use it for: scheduler priority queues, Dijkstra's algorithm, top-k elements, and merge-sorted-iterables.
import heapq
# Min-heap: smallest element always at index 0
tasks = []
heapq.heappush(tasks, (3, "low priority task"))
heapq.heappush(tasks, (1, "urgent task"))
heapq.heappush(tasks, (2, "normal task"))
heapq.heappop(tasks) # (1, 'urgent task') — always min
heapq.heappop(tasks) # (2, 'normal task')
# Top-k smallest/largest — O(n log k) — more efficient than full sort for small k
scores = [88, 42, 95, 67, 12, 73, 99, 56, 31, 84]
heapq.nlargest(3, scores) # [99, 95, 88]
heapq.nsmallest(3, scores) # [12, 31, 42]
# Merge sorted iterables — O(n log k) where k = number of iterables
import heapq
iter1 = [1, 4, 7]
iter2 = [2, 5, 8]
iter3 = [3, 6, 9]
list(heapq.merge(iter1, iter2, iter3))
# [1, 2, 3, 4, 5, 6, 7, 8, 9]
# Max-heap trick: negate the priority
events = [(3, "C"), (1, "A"), (4, "D"), (2, "B")]
max_heap = []
for priority, item in events:
heapq.heappush(max_heap, (-priority, item)) # negate for max behavior
heapq.heappop(max_heap) # (-4, 'D') — was highest priority
| Need | Best container | Why |
|---|---|---|
| Ordered, grows/shrinks at end | list | Default; O(1) amortized append/pop |
| Queue: add right, remove left | collections.deque | O(1) both ends |
| Sliding window / BFS frontier | collections.deque | O(1) pops; maxlen auto-evicts |
| Key-value mapping | dict | O(1) average lookup |
| Counting occurrences | collections.Counter | Built-in most_common(), arithmetic |
| Grouping (one-to-many) | collections.defaultdict(list) | Auto-creates empty list on first access |
| Fast membership test / dedup | set | O(1) membership; auto-deduplicates |
| Ordered set of unique items | dict (keys only, 3.7+) | Preserves insertion order unlike set |
| Fixed named record (immutable) | collections.namedtuple or frozen @dataclass | Hashable, named fields |
| Structured record with behavior | @dataclass | Auto-generates init/repr/eq |
| Priority queue / top-k | heapq | O(log n) push/pop, O(n log k) top-k |
| Large numeric array (homogeneous) | array.array or NumPy | Typed storage: 4–8× less memory than list |
Consulting lens: container choice as a design signal
In code reviews and technical interviews, the containers you reach for reveal your fluency. A candidate who writes manual counting loops when Counter exists, or uses dict.setdefault() in a loop when defaultdict is available, signals they're at API-awareness level. A candidate who picks Counter, defaultdict, heapq, and frozen dataclasses without prompting signals they've internalized the standard library — and that they'll write code teammates can maintain. The right container isn't just more efficient; it expresses intent in the code itself.
Knowledge check
InterviewYou need to count occurrences of each word in a document and get the three most common. What is the most Pythonic approach?
- A manual dict with a custom sort function written from scratch.
collections.Counter(words).most_common(3)— one call, purpose-built, O(n) counting + O(n log k) top-k.- Convert to a set, then count — sets track frequencies automatically.
- Sort the word list and walk it counting runs.
Answer
collections.Counter(words).most_common(3) — one call, purpose-built, O(n) counting + O(n log k) top-k.
Counter is a dict subclass purpose-built for tallying. Constructing from an iterable counts everything in one pass. .most_common(n) returns the top-n (element, count) pairs sorted by frequency — it uses a partial sort internally and is O(n log k) where k is the number requested. It's concise, correct, and faster than a hand-rolled solution.
Knowledge check
GotchaWhat error does Python raise for: @dataclass class Cfg: items: list = []?
- No error — Python silently shares the list across instances.
ValueError: mutable default <class 'list'> for field items is not allowed— usefield(default_factory=list)instead.TypeError: unhashable type 'list'.SyntaxError— type annotations in dataclasses can't uselist.
Answer
ValueError: mutable default <class 'list'> for field items is not allowed — use field(default_factory=list) instead.
Python 3.7+ dataclasses detect mutable defaults and raise ValueError at class definition time to prevent the shared-state bug. The fix is always items: list = field(default_factory=list) — the factory creates a fresh list for every new instance. This protection doesn't exist for plain classes, making dataclasses safer by design.
Knowledge check
InterviewYou need the single smallest item from a million-item list, added to one at a time. Which is better: maintaining a sorted list or using heapq?
- Sorted list is better because sorted() is highly optimized in Python.
heapq— each push/pop is O(log n) vs O(n) for inserting into a sorted list. For n insertions, heap is O(n log n) total vs O(n²) for maintaining a sorted list.- They are equivalent in performance.
- Use a set, since sets are always O(1).
Answer
heapq — each push/pop is O(log n) vs O(n) for inserting into a sorted list. For n insertions, heap is O(n log n) total vs O(n²) for maintaining a sorted list.
Maintaining a sorted list requires inserting each new element at the correct position — bisect + list.insert is O(log n) for finding the spot but O(n) for the actual insertion (shifting). Over n insertions: O(n²). A heap maintains the partial-order invariant with a single O(log n) "sift" operation on each push. For n pushes: O(n log n). Use heapq when you need minimum access and are adding elements one at a time.
Exercise: Event Analytics Pipeline
You have a stream of analytics events as a list of dicts with keys user_id, event_type, and revenue (may be 0.0 for non-purchase events). Write functions to: (1) count events per type using Counter, (2) group events by user using defaultdict, (3) find the top 5 users by total revenue using heapq. Write a UserStats dataclass to hold the per-user summary.
Show solution
from collections import Counter, defaultdict
from dataclasses import dataclass, field
import heapq
@dataclass
class UserStats:
user_id: str
event_count: int = 0
total_revenue: float = 0.0
event_types: list[str] = field(default_factory=list)
def analyze_events(events: list[dict]):
# 1. Count events per type
type_counts = Counter(e["event_type"] for e in events)
# 2. Group by user_id
by_user: dict[str, UserStats] = defaultdict(
lambda: UserStats(user_id="")
)
for event in events:
uid = event["user_id"]
stat = by_user[uid]
stat.user_id = uid
stat.event_count += 1
stat.total_revenue += event.get("revenue", 0.0)
stat.event_types.append(event["event_type"])
# 3. Top 5 by revenue using heapq.nlargest
top5 = heapq.nlargest(
5,
by_user.values(),
key=lambda s: s.total_revenue
)
return type_counts, dict(by_user), top5
# Sample data
events = [
{"user_id": "u1", "event_type": "view", "revenue": 0.0},
{"user_id": "u2", "event_type": "purchase", "revenue": 49.99},
{"user_id": "u1", "event_type": "purchase", "revenue": 199.0},
{"user_id": "u3", "event_type": "view", "revenue": 0.0},
{"user_id": "u2", "event_type": "purchase", "revenue": 89.0},
{"user_id": "u1", "event_type": "view", "revenue": 0.0},
]
type_counts, user_map, top5 = analyze_events(events)
print("Event types:", type_counts)
print("Top users by revenue:")
for stat in top5:
print(f" {stat.user_id}: ${stat.total_revenue:.2f} ({stat.event_count} events)")
Key takeaways
Countertallies any iterable;.most_common(n)returns top-n in O(n log k).defaultdict(factory)auto-creates missing values — eliminatessetdefault()boilerplate.@dataclassgenerates__init__/__repr__/__eq__; usefield(default_factory=list)for mutable defaults.heapqgives O(log n) push/pop and is the right tool for priority queues and top-k problems.- Frozen dataclasses are hashable records usable as dict keys and set members.
- Reaching for the precise container instead of over-using list/dict is a signal of engineering maturity.