dict is Python's default indexed data structure because keyed lookup is expressive and usually fast. The speed comes from a sparse hash table, not from magic syntax.
Core answer
Use dictionaries when keys are the model. A key must be hashable, and equality-relevant state must stay stable for as long as the key lives in the table.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclass@dataclass(frozen=True, slots=True)class InvoiceKey: tenant: str number: strdef index_amounts(rows: list[tuple[InvoiceKey, int]]) -> dict[InvoiceKey, int]: amounts: dict[InvoiceKey, int] = {} for key, cents in rows: amounts[key] = cents return amountsledger = index_amounts([(InvoiceKey("TEN-1", "INV-7"), 4200)])print(ledger[InvoiceKey("TEN-1", "INV-7")])Why this design exists
Hash tables trade memory for average constant-time lookup and update. Python uses that tradeoff for mappings because namespaces, caches, configuration, and indexes all need stable key semantics. The object model connects the table to user types through __hash__ and __eq__.
PEP 412 matters for instance dictionaries: CPython can share key layouts across instances that define the same attributes consistently.
Mechanics and CPython internals
CPython dicts use a compact ordered layout with a sparse index table and dense entries. A lookup hashes the key, probes candidate slots, and uses equality only when hashes and occupancy make a comparison relevant. Deletions leave tombstone-like states until table maintenance; resizing preserves probe performance under growth. Insertion order is a language guarantee in Python 3.7+; compact layout details remain implementation territory.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclassfrom sys import getsizeof@dataclass(frozen=True, slots=True)class Region: code: strdef report_growth(counts: tuple[int, ...]) -> None: for count in counts: table = {Region(str(index)): index for index in range(count)} print(count, getsizeof(table), list(table)[:2])report_growth((0, 1, 5, 6, 16, 32))Complexity and tradeoffs
Lookup, insertion, and deletion are average O(1) under a healthy hash distribution. Worst-case collision behavior is worse, and a custom hash that collapses many keys destroys throughput even when correctness survives. The table spends extra memory on capacity and indexes to keep probe chains short.
Idiomatic patterns and refactoring
Prefer one lookup that states the fallback over a membership probe followed by a second keyed access.
# [CURRENT - 3.10-3.14] Works on Python 3.10+from dataclasses import dataclass@dataclass(frozen=True, slots=True)class EndpointConfig: timeout_ms: intdef timeout_bad(raw: dict[str, EndpointConfig], name: str) -> int: if name in raw: return raw[name].timeout_ms return 500def timeout(raw: dict[str, EndpointConfig], name: str) -> int: return raw.get(name, EndpointConfig(500)).timeout_msconfigs = {"billing": EndpointConfig(250)}print(timeout_bad(configs, "billing"))print(timeout(configs, "ledger"))Common mistakes and edge cases
Do not mutate a hash-bearing key after insertion. Remember that values such as 1, 1.0, and True compare equal and therefore share mapping-key semantics. Do not assume insertion ordering makes a dict a sorted structure.
When to use / When NOT to use
Use a dict for keyed records, indexes, caches, counters, and namespaces. Use a list or tuple when positional order is the contract, and use a set when values are only membership keys.
Do not pick dict for "fast" by reflex when key construction, mutation discipline, or memory pressure is the actual problem.