Choosing the Right Data Structure
Pick containers by how you read, update, and traverse data - not by habit. The wrong structure costs clarity and asymptotic performance; the right one makes code shorter and faster without micro-optimizing.
Search across all documentation pages
Pick containers by how you read, update, and traverse data - not by habit. The wrong structure costs clarity and asymptotic performance; the right one makes code shorter and faster without micro-optimizing.
Ask four questions before coding:
Map answers to dict, set, list, deque, heapq, or dataframe libraries.
When to reach for this:
from collections import defaultdict, deque
import heapq
# Pattern: index users by id -> dict
users_by_id: dict[int, dict] = {1: {"name": "Ada"}, 2: {"name": "Linus"}}
# Pattern: dedupe tags -> set
tags = set(["py", "ml", "py"])
# Pattern: FIFO job queue -> deque
jobs: deque[str] = deque(["a", "b"])
# Pattern: priority scheduling -> heapq
heap: list[tuple[int, str]] = []
heapq.heappush(heap, (2, "low"))
heapq.heappush(heap, (1, "high"))
# Pattern: group rows -> defaultdict
by_role: defaultdict[str, list[str]] = defaultdict(list)
for name, role in [("Ada", "admin"), ("Linus", "dev")]:
by_role[role].append(name)What this demonstrates:
pop(0) penalty| Access pattern | Prefer | Why |
|---|---|---|
| Lookup by unique key | dict | O(1) average |
| Membership test | set | O(1) average |
| Ordered append + scan | list | Simple, cache-friendly |
| Queue/stack both ends | deque | O(1) pops |
| Top-K / schedule | heapq | O(log n) updates |
| Columnar analytics | pandas/polars | Vectorized ops |
dict[node, set[neighbor]] combines dict + set.# smell: repeated linear search
if item in big_list: # O(n) each time
# fix: build set once
seen = set(big_list)
if item in seen: # O(1) averageif x in items on large list in loop → O(n²). Fix: Prebuild set(items).move_to_end.sorted. Fix: heap when k << n or streaming.| Alternative | Use When | Don't Use When |
|---|---|---|
| SQLite in-memory | Relational queries on moderate data | Simple counter |
| Redis | Shared cache across processes | Single-process script |
| ORM models | Persistent domain entities | Ephemeral algorithmic buffer |
slots classes | Many small fixed-field objects | Dynamic JSON blobs |
list.append/pop is fine for stack-only. Use deque when popping from left or both ends.
Column-wise stats, joins, filtering on millions of rows - not for 30-key config dict.
Tuple/NamedTuple for small immutable bundles. dataclass when defaults, methods, or mutability needed.
Pick natural domain key. Int ids faster and smaller than stringified ids when numeric.
dict[node, list[neighbor]] or defaultdict(set) for sparse graphs. NetworkX for algorithms library.
Counter when doing multiset math or most_common. Plain dict fine for single-pass tally.
bisect for mostly sorted insertions; heap for dynamic min/max extraction.
Dict keys, set elements, hash caches, shared read-only config - tuple/frozenset/bytes.
No semantic order - if display order matters, use list or OrderedDict pattern with dict.fromkeys.
One-line comment or ADR when non-obvious - future readers inherit context.
Stack versions: This page was written for Python 3.14.0 (stable 3.14, maintenance 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.
Reviewed by Chris St. John·Last updated Jul 16, 2026