I’ve shipped plenty of Python systems where the data structure choice determined whether the code stayed readable six months later. The built-in list, dict, tuple, and set are great, but they’re general-purpose. When the data has a predictable shape or a particular access pattern, the collections module gives you specialized containers that do the right thing with less code, fewer bugs, and better performance. If you’ve ever written a manual frequency counter, built a queue with a list, or passed around records as loose tuples, you already felt this pain.
You’ll see how each container works, where it shines, and where it can hurt you. I’ll show practical examples you can paste into a REPL, plus common mistakes I’ve seen in production. I’ll also include guidance I use in 2026 codebases that rely on async pipelines, data-heavy services, and AI-assisted code review workflows. You should finish with a clear mental model: when to reach for Counter, deque, defaultdict, namedtuple, OrderedDict, and ChainMap, and when to stay with plain dict and list.
Why I reach for collections in real projects
When data grows or your team grows, clarity beats cleverness. The collections module exists because Python’s general containers are intentionally neutral. Specialized containers encode intent. That makes code easier to read, reduces defensive checks, and usually yields better algorithmic behavior.
Here’s how I frame the decision:
- If your data structure represents counts, pick Counter.
- If you need a queue or stack with fast ends, pick deque.
- If you need defaults on missing keys, pick defaultdict.
- If you need a named record, pick namedtuple (or dataclasses when you want mutability).
- If you need key order manipulation, pick OrderedDict.
- If you need layered scopes, pick ChainMap.
That’s not just personal preference. It’s about expressing the invariants of the data. If you store counts in a dict, you’re telling future readers, “this dict is different; please infer how.” Counter tells them directly.
Counter: frequency and multiset logic without boilerplate
Counter is a dict subclass for counting hashable items. It behaves like a multiset: items have counts, not just presence. I use it for analytics, log aggregation, and quick data summaries.
The most important behaviors:
- Missing keys default to zero.
- You can add or subtract Counters.
- You can fetch the most common items easily.
Here’s a runnable example with realistic data:
from collections import Counter
Frequency from a list
status_codes = [200, 200, 404, 500, 200, 503, 404, 200, 302, 404]
counts = Counter(status_codes)
print(counts)
Top 2 most common
print(counts.most_common(2))
Update from another batch
more = [200, 200, 200, 404]
counts.update(more)
print(counts)
You should also know Counter supports subtraction, which is useful for diffing datasets:
from collections import Counter
before = Counter({"oak": 5, "maple": 3, "pine": 7})
after = Counter({"oak": 4, "maple": 5, "pine": 2})
Items removed
removed = before - after
print(removed) # Counter({‘pine‘: 5, ‘oak‘: 1})
Items added
added = after - before
print(added) # Counter({‘maple‘: 2})
Counter in practice: session analytics
A common task is counting events across many user sessions. I like Counter because it merges cleanly and keeps the algorithm explicit.
from collections import Counter
def count_events(sessions):
total = Counter()
for session in sessions:
total.update(event["type"] for event in session["events"])
return total
sessions = [
{"user": "u1", "events": [{"type": "click"}, {"type": "view"}]},
{"user": "u2", "events": [{"type": "view"}, {"type": "view"}]},
]
print(count_events(sessions))
Edge cases and footguns
- Counter allows zero and negative counts. Subtraction retains only positive counts by default, but direct assignment can create zero or negative values. If you’re using Counter to represent a multiset, filter or remove non-positive entries.
- Counter treats missing keys as zero, which is convenient for arithmetic but can hide mistakes. If you misspell a key, you won’t get a KeyError.
Example fix for negative values:
cleaned = Counter({k: v for k, v in counts.items() if v > 0})
When not to use it
- If you only need a set of unique items, use set.
- If counts are float-based (weights), use a dict and manage precision yourself.
- If cardinality is huge (millions of unique keys), you may need approximate methods or external systems.
Counter alternatives
- Manual dict counting is fine in tiny scripts, but the counter intent is clearer.
- If you need time-decay counts, consider a custom structure (like per-window Counters plus aggregation) rather than a single Counter.
deque: a queue that doesn’t fight you
A list is great at appending on the right, but removing from the left is O(n). A deque is made for fast operations at both ends. I use it for task queues, sliding windows, and BFS in graphs.
Here’s a classic log window example:
from collections import deque
Track the last 5 request latencies
latencies = deque(maxlen=5)
new_samples = [120, 95, 130, 110, 105, 140, 90]
for ms in new_samples:
latencies.append(ms)
print(list(latencies))
If you need a FIFO queue:
from collections import deque
queue = deque(["job-101", "job-102", "job-103"])
queue.append("job-104") # enqueue
next_job = queue.popleft() # dequeue
print(next_job, queue)
If you need a stack, it works too:
from collections import deque
stack = deque()
stack.append("txn-1")
stack.append("txn-2")
print(stack.pop())
deque in practice: sliding window aggregation
I often use deque to keep a rolling window and compute moving averages or percentiles.
from collections import deque
window = deque(maxlen=3)
values = [10, 20, 30, 40, 50]
for v in values:
window.append(v)
avg = sum(window) / len(window)
print(f"{list(window)} -> avg={avg}")
Edge cases and footguns
- deque doesn’t support slicing. If you need to slice or index deep, convert to list or use list from the start.
- Be careful with maxlen when you need to reason about dropped items. The oldest items fall off silently.
When not to use it
- When you need random access and slicing. list is still the best choice there.
- When you need to remove arbitrary items in the middle often. deque removal is still linear if you search inside.
deque vs queue module
If you need thread-safe queues, use queue.Queue or asyncio.Queue. deque is fast but not thread-safe for producer-consumer patterns across threads.
defaultdict: defaults without repetitive checks
defaultdict is my go-to for grouping data or building indexes. You set a default factory and missing keys automatically create a default value. It removes the “if key not in dict” pattern everywhere.
Grouping example (user orders):
from collections import defaultdict
orders = [
("alice", "laptop"),
("maya", "keyboard"),
("alice", "mouse"),
("ken", "monitor"),
("maya", "mouse"),
]
by_user = defaultdict(list)
for user, item in orders:
by_user[user].append(item)
print(by_user)
Counting example without Counter, for comparison:
from collections import defaultdict
votes = ["yes", "no", "yes", "yes", "no", "abstain"]
counts = defaultdict(int)
for v in votes:
counts[v] += 1
print(counts)
defaultdict in practice: inverted index
I use this pattern for search-like features or tag lookups.
from collections import defaultdict
docs = {
"doc1": ["python", "collections", "deque"],
"doc2": ["python", "dict"],
"doc3": ["collections", "counter"],
}
index = defaultdict(set)
for doc_id, tags in docs.items():
for tag in tags:
index[tag].add(doc_id)
print(index["collections"]) # {‘doc1‘, ‘doc3‘}
Edge cases and footguns
- Accessing a missing key mutates the dict. This is a big debugging surprise in code that expects reads to be side-effect free.
- The default factory must be a callable, not an instance. Use list, not [] . Use set, not set().
When not to use it
- When you need to detect missing keys and treat them specially. Use a normal dict and handle KeyError or
inchecks. - When you’re serializing to formats that don’t like custom dict subclasses. Convert to dict first.
Alternative approaches
dict.setdefaultis handy for one-off grouping, but I find it less readable in loops.- For deep nested structures, consider
defaultdictwith lambdas, but it can get too clever fast. If the nesting is complex, a small helper function can be clearer.
namedtuple: lightweight records with intent
namedtuple creates tuple subclasses with named fields. It’s immutable and fast, which makes it great for read-heavy records and functional style pipelines. If you need mutable fields, use dataclasses instead.
Here’s a clean log record example:
from collections import namedtuple
LogEntry = namedtuple("LogEntry", ["timestamp", "level", "message"])
entry = LogEntry("2026-01-18T10:22:11Z", "INFO", "service started")
print(entry.level, entry.message)
tuples are immutable
entry.level = "WARN" # would raise AttributeError
I like namedtuple for: parsing CSVs, returning multiple values from functions, or moving data across layers without a heavy class.
namedtuple in practice: structured parsing
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"]) # simple geometry
def parse_point(line):
xstr, ystr = line.split(",")
return Point(float(xstr), float(ystr))
p = parse_point("3.5,4.2")
print(p.x, p.y)
Edge cases and footguns
- namedtuple fields are positional too. If you rely on order and later reorder fields, old code may break silently. I prefer keyword arguments for clarity.
- No built-in validation. You can’t enforce types or ranges without wrapping in another layer.
When not to use it
- If you need mutability or type enforcement. Choose dataclasses or pydantic models.
- If you need methods, invariants, or behavior, write a class.
namedtuple vs dataclass
- namedtuple is lighter and faster for read-only records.
- dataclass is more flexible and can be mutable. It’s a better fit when you need validation or defaults.
OrderedDict: order control when order means semantics
Regular dicts preserve insertion order in modern Python, but OrderedDict still has features that justify its existence. The key one: you can move keys to the end or start, which is great for LRU cache logic or order-sensitive workflows.
Basic usage:
from collections import OrderedDict
cache = OrderedDict()
cache["user:1"] = {"name": "Ava"}
cache["user:2"] = {"name": "Luis"}
cache["user:3"] = {"name": "Ravi"}
Access user:1 and mark as most recent
cache.movetoend("user:1")
Evict least recent
least_recent = next(iter(cache))
cache.pop(least_recent)
print(cache)
In 2026 I often combine OrderedDict with async services to maintain deterministic order for batching or to stabilize cache eviction in distributed tasks.
OrderedDict in practice: LRU cache
from collections import OrderedDict
class LRUCache:
def init(self, capacity):
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return None
self.data.movetoend(key)
return self.data[key]
def put(self, key, value):
if key in self.data:
self.data.movetoend(key)
self.data[key] = value
if len(self.data) > self.capacity:
self.data.popitem(last=False)
cache = LRUCache(2)
cache.put("a", 1)
cache.put("b", 2)
cache.get("a")
cache.put("c", 3)
print(cache.data) # b gets evicted
Edge cases and footguns
- Using OrderedDict without a real need. If you only need insertion order, a dict is fine and faster to read.
- Forgetting that movetoend affects order and can subtly change iteration results.
When not to use it
- When order isn’t a semantic part of the data. Use dict.
- When you need performance with frequent order changes and huge datasets; sometimes a custom structure is more efficient.
ChainMap: layered scopes without copying
ChainMap is a lightweight view over multiple dicts. It lets you search through multiple mappings as if they were one. I use it for configuration: environment overrides, then user config, then defaults.
Example: layered config:
from collections import ChainMap
base = {"timeout": 30, "retries": 2, "region": "us-east"}
user = {"retries": 4}
env = {"timeout": 10}
config = ChainMap(env, user, base)
print(config["timeout"], config["retries"], config["region"])
If you update the ChainMap, it writes to the first mapping:
config["region"] = "us-west"
print(env) # region now lives in env
ChainMap in practice: scoped lookups
This is useful in REPL-like systems or templating engines where there are local variables and globals.
from collections import ChainMap
globals = {"pi": 3.14159}
locals = {"radius": 2}
scope = ChainMap(locals, globals)
area = scope["pi"] scope["radius"] * 2
print(area)
Edge cases and footguns
- Assuming updates propagate to the original dict containing that key. Updates always go to the first map unless you mutate a specific map directly.
- Using ChainMap for deep merges. It’s shallow; nested dicts won’t merge. You’ll need a custom merge for that.
When not to use it
- When you need a concrete merged dict. Use a merge function or {a, b} if you need a copy.
- When you require mutations to be reflected in the underlying map that originally had the key; ChainMap doesn’t work that way.
deque vs list, Counter vs dict: a quick decision table
When I teach teams, I like a simple comparison table so everyone can quickly decide.
Traditional choice
Why I prefer it
—
—
list
O(1) popleft, clearer intent
dict with manual checks
shorter code, built-in utilities
dict with if-checks
avoids repeated existence checks
tuple
clearer field access
dict
movetoend and order control
dict update
no copying, simple override chain## Performance notes I use in code reviews
I’m not chasing micro-optimizations, but I do care about predictable performance. Here’s the mental checklist I use when reviewing code:
- deque operations on both ends are typically O(1), while list.pop(0) can be O(n).
- Counter has overhead but is still fast enough for millions of items; for massive cardinalities you might need approximate methods.
- defaultdict reduces branching, which makes code simpler and typically faster in tight loops.
- namedtuple is memory-efficient compared to custom classes but less flexible.
- ChainMap is lightweight, but repeated lookups across many layers can add a small cost; keep the number of layers reasonable.
In my experience, these containers are often the fastest way to reduce code size without sacrificing clarity. The biggest wins come from avoiding unnecessary copying and reducing complex conditional logic.
Common mistakes and how I avoid them
I’ll list the ones that keep showing up in production PRs:
1) Using dict for queues, then slicing and popping to simulate FIFO. That’s a red flag. deque exists for this.
2) Using Counter but forgetting it’s not sorted. If you need sorted output, call most_common or sort the items explicitly.
3) Using defaultdict and then serializing the object in a way that exposes the default factory. Convert to dict when you need a plain mapping.
4) Using namedtuple but then needing mutation. That’s a sign you wanted a dataclass.
5) Layering configs with ChainMap but then expecting a single dict to pass to a library. Many libraries need a concrete dict, so convert with dict(chainmap).
If you remember nothing else, remember this: pick the container that matches your data’s behavior, not just its shape.
Patterns I recommend in 2026 codebases
A lot of modern Python systems mix async IO, streaming data, and AI-assisted development. These containers still matter, and a few patterns keep paying off.
Pattern: streaming counts with Counter and periodic flush
When I analyze logs, I batch counts to avoid unbounded memory growth.
from collections import Counter
counter = Counter()
for event in event_stream():
counter[event.type] += 1
if sum(counter.values()) >= 10000:
flush(counter)
counter.clear()
Pattern: sliding window with deque for latency percentiles
from collections import deque
import statistics
latencies = deque(maxlen=500)
for ms in latency_stream():
latencies.append(ms)
if len(latencies) == latencies.maxlen:
p95 = statistics.quantiles(latencies, n=100)[94]
report(p95)
Pattern: layered configuration for services and tests
from collections import ChainMap
def loadconfig(env, userconfig):
defaults = {"timeout": 20, "retries": 3}
return ChainMap(env, user_config, defaults)
These patterns keep data flows clear and reduce error handling. They also translate well into code generated with AI tools, because the intent is explicit.
When not to use collections at all
You should still keep things simple when the data is simple. Here are cases where I avoid specialized containers:
- Tiny scripts with trivial data sizes where clarity is already obvious.
- Highly custom data structures where you need validation or methods: use dataclasses or custom classes.
- Cases where library interfaces strictly require built-in types. Some external libraries don’t like Counter or defaultdict because of how they serialize.
I’m not dogmatic about it. I’m pragmatic. If a dict is the simplest and clearest choice, take it. But don’t ignore the tools built for the job.
A deeper mental model: intent, invariants, and failure modes
This is the framework I teach: pick containers that encode intent, enforce invariants, and reduce failure modes.
- Intent: A Counter says “counting,” which reduces the need for comments.
- Invariants: A deque with maxlen says “keep only the last N.”
- Failure modes: defaultdict hides missing keys, which prevents KeyError but can mask mistakes.
When you choose a container, you’re also choosing your failure mode. In my experience, the most dangerous errors are the silent ones. So if a bug would be catastrophic, I avoid data structures that silently create new keys or default values.
Practical scenarios by domain
Sometimes the best way to learn is by seeing how the containers show up in different domains.
Web services
- Counter: status code metrics, error categories
- deque: per-instance request sampling window
- defaultdict: route-to-handler registry
- OrderedDict: LRU cache for in-memory response caching
- ChainMap: environment overrides layered with defaults
Data pipelines
- Counter: categorical distributions, quick histograms
- deque: rolling window for anomaly detection
- defaultdict: group-by transformations
- namedtuple: structured rows flowing through pipeline stages
ML and analytics
- Counter: vocabulary counts, label distributions
- defaultdict: feature aggregation by user
- namedtuple: immutable feature vectors for reproducibility
Edge cases worth testing
If you’re relying on these containers in production, I recommend explicitly testing the following:
- Counter subtraction with missing keys
- deque maxlen behavior when full
- defaultdict creation side effects during reads
- namedtuple keyword vs positional initialization
- OrderedDict movetoend with missing keys
- ChainMap updates and shadowing behavior
Tiny tests like these prevent confusing production bugs.
Alternatives and when they win
Sometimes a different approach is better.
- Use dataclasses for records that need methods, validation, or defaults.
- Use lists and dicts when speed of comprehension matters more than specialized behavior.
- Use third-party structures for large-scale tasks (e.g., LRU caches, probabilistic counters, or sorted containers).
The collections module is a powerful middle ground: more structure than raw lists and dicts, but lighter than custom classes.
A short compatibility note
If you’re writing library code, think about serialization. defaultdict and Counter can serialize to JSON, but you may need to convert to dict first. ChainMap and OrderedDict are generally safe, but some frameworks might not treat them exactly like dict. When in doubt, normalize to a plain dict at your API boundary.
Key takeaways and next steps
If you want your Python code to stay readable as it grows, you should treat data structure choices as part of your design, not as afterthoughts. The collections module gives you specialized containers that encode intent and reduce boilerplate. Counter makes counting honest and clean. deque is the right tool for queues and sliding windows. defaultdict simplifies grouping and indexing. namedtuple gives you lightweight, readable records. OrderedDict is for explicit order control. ChainMap keeps layered configs sane without unnecessary copying.
My practical advice: pick one area of your current codebase and replace a hand-rolled data structure with a collections container. If you have a queue, switch to deque. If you have manual count logic, switch to Counter. If you have repeated “if key not in dict” patterns, swap in defaultdict. Don’t refactor everything at once; do it where it hurts, then measure clarity and performance.
Once you’ve done that, codify the pattern. Add a short comment or a small utility function so the intent remains clear to your team. In 2026, with AI-assisted reviews and fast-moving codebases, the most valuable thing you can do is make your intent unmistakable.


