Python — Dictionaries
Dictionaries (dict) are Python's built-in mapping type. They store key-value pairs with O(1) average-time lookups, inserts, and deletes. Since Python 3.7, dictionaries preserve insertion order, making them predictable and versatile for a wide range of applications — from configuration stores to memoization caches to structured data records.
Python offers multiple ways to create dictionaries, each suited to different use cases. The literal syntax is most common, but factory functions and comprehensions provide concise alternatives.
| 1 | # Literal syntax — most common and readable |
| 2 | empty = {} |
| 3 | user = {"name": "Alice", "age": 30, "role": "admin"} |
| 4 | |
| 5 | # dict() constructor — keyword args (keys must be valid identifiers) |
| 6 | user = dict(name="Alice", age=30, role="admin") |
| 7 | |
| 8 | # dict() from iterable of pairs |
| 9 | pairs = [("name", "Alice"), ("age", 30)] |
| 10 | user = dict(pairs) # {"name": "Alice", "age": 30} |
| 11 | |
| 12 | # dict.fromkeys — all keys map to the same value |
| 13 | keys = ["x", "y", "z"] |
| 14 | d = dict.fromkeys(keys, 0) # {"x": 0, "y": 0, "z": 0} |
| 15 | |
| 16 | # dict.fromkeys — default None |
| 17 | d = dict.fromkeys(keys) # {"x": None, "y": None, "z": None} |
| 18 | |
| 19 | # zip — parallel sequences into key-value pairs |
| 20 | names = ["alice", "bob", "charlie"] |
| 21 | ages = [30, 25, 35] |
| 22 | users = dict(zip(names, ages)) |
| 23 | # {"alice": 30, "bob": 25, "charlie": 35} |
| 24 | |
| 25 | # Dict comprehension — flexible and expressive |
| 26 | squares = {x: x ** 2 for x in range(5)} |
| 27 | # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16} |
| 28 | |
| 29 | # Comprehension with filter |
| 30 | even_squares = {x: x ** 2 for x in range(10) if x % 2 == 0} |
| 31 | # {0: 0, 2: 4, 4: 16, 6: 36, 8: 64} |
| 32 | |
| 33 | # Nested dict comprehension |
| 34 | matrix = {(i, j): i * j for i in range(3) for j in range(3)} |
info
Python provides bracket notation for direct access and two safe methods — get() and setdefault() — for handling missing keys gracefully.
| 1 | d = {"name": "Alice", "age": 30} |
| 2 | |
| 3 | # Bracket access — raises KeyError if missing |
| 4 | print(d["name"]) # Alice |
| 5 | # print(d["missing"]) # KeyError: 'missing' |
| 6 | |
| 7 | # .get() — returns None (or default) if missing |
| 8 | print(d.get("name")) # Alice |
| 9 | print(d.get("missing")) # None |
| 10 | print(d.get("missing", "N/A")) # "N/A" |
| 11 | |
| 12 | # .setdefault() — returns value if exists, else inserts default |
| 13 | d.setdefault("role", "user") # inserts "role": "user" |
| 14 | d.setdefault("name", "Bob") # name exists, no-op (returns "Alice") |
| 15 | print(d) # {"name": "Alice", "age": 30, "role": "user"} |
| 16 | |
| 17 | # .setdefault() with mutable default — single object, careful! |
| 18 | d.setdefault("tags", []).append("admin") |
| 19 | d.setdefault("tags", []).append("staff") |
| 20 | print(d["tags"]) # ["admin", "staff"] |
| 21 | |
| 22 | # Check existence before access |
| 23 | if "name" in d: |
| 24 | print(d["name"]) # Alice |
| 25 | |
| 26 | # Nested safe access pattern |
| 27 | config = {"database": {"host": "localhost", "port": 5432}} |
| 28 | host = config.get("database", {}).get("host", "localhost") |
best practice
Dictionaries are mutable. You can add, update, and remove entries with several methods and operators.
| 1 | d = {"a": 1, "b": 2} |
| 2 | |
| 3 | # Add or update — bracket assignment |
| 4 | d["c"] = 3 # {"a": 1, "b": 2, "c": 3} |
| 5 | d["a"] = 10 # {"a": 10, "b": 2, "c": 3} |
| 6 | |
| 7 | # .update() — merge another dict or iterable of pairs |
| 8 | d.update({"d": 4, "e": 5}) |
| 9 | d.update(f=6, g=7) # keyword args |
| 10 | d.update([("h", 8), ("i", 9)]) # iterable of pairs |
| 11 | |
| 12 | # .pop() — remove and return value (raises KeyError if missing) |
| 13 | val = d.pop("a") # 10 |
| 14 | val = d.pop("zzz", "fallback") # "fallback" (no error) |
| 15 | |
| 16 | # .popitem() — remove and return last inserted pair (LIFO) |
| 17 | key, val = d.popitem() # ("i", 9) in 3.7+ |
| 18 | |
| 19 | # .clear() — remove all items |
| 20 | d.clear() # {} |
| 21 | |
| 22 | # del statement — delete a key |
| 23 | d2 = {"x": 1, "y": 2, "z": 3} |
| 24 | del d2["y"] # {"x": 1, "z": 3} |
| 25 | |
| 26 | # Safe deletion with pop |
| 27 | d2.pop("missing", None) # None, no error |
| 28 | |
| 29 | # Conditional deletion |
| 30 | if "x" in d2: |
| 31 | del d2["x"] |
info
Dictionaries support three iteration views: keys, values, and items. Since Python 3.7, iteration order matches insertion order.
| 1 | d = {"name": "Alice", "age": 30, "role": "admin"} |
| 2 | |
| 3 | # Iterate over keys (default) |
| 4 | for key in d: |
| 5 | print(key) # name → age → role |
| 6 | |
| 7 | # .keys() — explicit key iteration |
| 8 | for key in d.keys(): |
| 9 | print(key) |
| 10 | |
| 11 | # .values() — iterate over values |
| 12 | for val in d.values(): |
| 13 | print(val) # Alice → 30 → admin |
| 14 | |
| 15 | # .items() — iterate over key-value pairs (most common) |
| 16 | for key, val in d.items(): |
| 17 | print(f"{key}={val}") |
| 18 | |
| 19 | # Unpacking keys |
| 20 | k1, k2, k3 = d # k1="name", k2="age", k3="role" |
| 21 | |
| 22 | # Convert views to lists |
| 23 | key_list = list(d.keys()) |
| 24 | val_list = list(d.values()) |
| 25 | item_list = list(d.items()) |
| 26 | |
| 27 | # Reverse iteration |
| 28 | for key in reversed(d): |
| 29 | print(key) # role → age → name |
| 30 | |
| 31 | # Sorted iteration |
| 32 | for key in sorted(d): |
| 33 | print(key) # age → name → role |
| 34 | |
| 35 | # Filtered iteration |
| 36 | for key, val in d.items(): |
| 37 | if isinstance(val, str): |
| 38 | print(f"{key}: {val}") |
| 39 | |
| 40 | # Destructuring in comprehensions |
| 41 | result = {k: v.upper() if isinstance(v, str) else v for k, v in d.items()} |
The objects returned by .keys(), .values(), and .items() are dynamic views — they reflect changes to the underlying dict without creating a new object.
| 1 | d = {"a": 1, "b": 2} |
| 2 | |
| 3 | keys = d.keys() |
| 4 | values = d.values() |
| 5 | items = d.items() |
| 6 | |
| 7 | print(list(keys)) # ["a", "b"] |
| 8 | |
| 9 | # Modify the dict — views update automatically |
| 10 | d["c"] = 3 |
| 11 | del d["a"] |
| 12 | |
| 13 | print(list(keys)) # ["b", "c"] — dynamic! |
| 14 | print(list(items)) # [("b", 2), ("c", 3)] |
| 15 | |
| 16 | # Set-like operations on .keys() (Python 3+) |
| 17 | d1 = {"a": 1, "b": 2, "c": 3} |
| 18 | d2 = {"b": 20, "c": 30, "d": 40} |
| 19 | |
| 20 | print(d1.keys() & d2.keys()) # {"b", "c"} — intersection |
| 21 | print(d1.keys() | d2.keys()) # {"a", "b", "c", "d"} — union |
| 22 | print(d1.keys() - d2.keys()) # {"a"} — difference |
| 23 | print(d1.keys() ^ d2.keys()) # {"a", "d"} — symmetric diff |
| 24 | |
| 25 | # .items() also supports set operations (values must be hashable) |
| 26 | print(d1.items() & d2.items()) # empty — values differ |
| 27 | |
| 28 | # .values() does NOT support set ops (values may not be hashable) |
| 29 | |
| 30 | # Iteration safety — avoid modifying size during iteration |
| 31 | d = {"a": 1, "b": 2, "c": 3} |
| 32 | # for k in d: # RuntimeError if you add/delete |
| 33 | # if k == "b": |
| 34 | # del d[k] # !!! RuntimeError: dict changed size |
| 35 | |
| 36 | # Safe — iterate over a copy |
| 37 | for k in list(d): |
| 38 | if k == "b": |
| 39 | del d[k] # OK — iterating over copy |
best practice
Python 3.9 introduced the | operator for merging dicts. Prior versions relied on update() or the {**d1, **d2} spread pattern.
| 1 | a = {"x": 1, "y": 2} |
| 2 | b = {"y": 3, "z": 4} |
| 3 | |
| 4 | # | operator (Python 3.9+) — returns new dict |
| 5 | merged = a | b # {"x": 1, "y": 3, "z": 4} |
| 6 | merged = b | a # {"x": 1, "y": 2, "z": 4} (right side wins) |
| 7 | |
| 8 | # |= — update in-place (Python 3.9+) |
| 9 | a |= b # a is now {"x": 1, "y": 3, "z": 4} |
| 10 | |
| 11 | # ** spread (Python 3.5+) — creates new dict |
| 12 | merged = {**a, **b} |
| 13 | merged = {**a, "extra": 42, **b} |
| 14 | |
| 15 | # .update() — in-place merge |
| 16 | a.update(b) |
| 17 | |
| 18 | # Merge with comprehension — transform during merge |
| 19 | keys = {"x": 1, "y": 2} |
| 20 | overrides = {"y": 99} |
| 21 | merged = {k: overrides.get(k, v) for k, v in {**keys, **overrides}.items()} |
| 22 | |
| 23 | # Deep merge helper (shallow merge only) |
| 24 | def deep_merge(base: dict, overlay: dict) -> dict: |
| 25 | result = base.copy() |
| 26 | for key, val in overlay.items(): |
| 27 | if key in result and isinstance(result[key], dict) and isinstance(val, dict): |
| 28 | result[key] = deep_merge(result[key], val) |
| 29 | else: |
| 30 | result[key] = val |
| 31 | return result |
| 32 | |
| 33 | config = {"db": {"host": "localhost", "port": 5432}} |
| 34 | env = {"db": {"port": 15432}} |
| 35 | merged = deep_merge(config, env) |
| 36 | # {"db": {"host": "localhost", "port": 15432}} |
info
Python's collections module provides specialized dict variants for common patterns: defaultdict, Counter, OrderedDict, and ChainMap.
defaultdict
A defaultdict calls a factory function to supply missing keys automatically, eliminating the need for explicit checks.
| 1 | from collections import defaultdict |
| 2 | |
| 3 | # List factory — group items |
| 4 | words = ["apple", "banana", "apple", "cherry", "banana", "apple"] |
| 5 | groups = defaultdict(list) |
| 6 | for word in words: |
| 7 | groups[len(word)].append(word) |
| 8 | # {5: ["apple", "apple", "apple"], 6: ["banana", "banana"], 6: ["cherry"]} |
| 9 | |
| 10 | # Int factory — count occurrences |
| 11 | counter = defaultdict(int) |
| 12 | for word in words: |
| 13 | counter[word] += 1 |
| 14 | # {"apple": 3, "banana": 2, "cherry": 1} |
| 15 | |
| 16 | # Set factory — collect unique pairs |
| 17 | pairs = [("a", 1), ("b", 2), ("a", 3)] |
| 18 | d = defaultdict(set) |
| 19 | for k, v in pairs: |
| 20 | d[k].add(v) |
| 21 | # {"a": {1, 3}, "b": {2}} |
| 22 | |
| 23 | # Custom factory |
| 24 | from datetime import datetime |
| 25 | timestamps = defaultdict(datetime.now) # each access sets current time |
| 26 | |
| 27 | # Nested defaultdict (auto-vivification) |
| 28 | nested = defaultdict(lambda: defaultdict(list)) |
| 29 | nested["users"]["alice"].append("login") |
| 30 | nested["users"]["bob"].append("logout") |
Counter
A Counter is a defaultdict(int) subclass specialized for counting hashable objects.
| 1 | from collections import Counter |
| 2 | |
| 3 | # Count elements |
| 4 | cnt = Counter(["a", "b", "a", "c", "a", "b"]) |
| 5 | # Counter({"a": 3, "b": 2, "c": 1}) |
| 6 | |
| 7 | # Count from string |
| 8 | cnt = Counter("abracadabra") |
| 9 | # Counter({"a": 5, "b": 2, "r": 2, "c": 1, "d": 1}) |
| 10 | |
| 11 | # Most common |
| 12 | print(cnt.most_common(2)) # [("a", 5), ("b", 2)] |
| 13 | |
| 14 | # Arithmetic |
| 15 | c1 = Counter(a=3, b=1) |
| 16 | c2 = Counter(a=1, b=2, c=3) |
| 17 | print(c1 + c2) # Counter({"a": 4, "b": 3, "c": 3}) |
| 18 | print(c1 - c2) # Counter({"a": 2}) (zeros removed) |
| 19 | print(c1 & c2) # Counter({"a": 1, "b": 1}) (intersection — min) |
| 20 | print(c1 | c2) # Counter({"a": 3, "b": 2, "c": 3}) (union — max) |
| 21 | |
| 22 | # Total (Python 3.10+) |
| 23 | print(cnt.total()) # 11 |
| 24 | |
| 25 | # Elements iterator |
| 26 | list(Counter(a=3, b=1).elements()) # ["a", "a", "a", "b"] |
| 27 | |
| 28 | # Update and subtract |
| 29 | cnt.update("abc") # add counts |
| 30 | cnt.subtract("a") # subtract (can go negative) |
OrderedDict
Before Python 3.7 guaranteed dict ordering, OrderedDict was the go-to for ordered mappings. It is still useful when order-sensitive equality matters or when you need to move items to the end.
| 1 | from collections import OrderedDict |
| 2 | |
| 3 | # OrderedDict remembers insertion order |
| 4 | od = OrderedDict() |
| 5 | od["a"] = 1 |
| 6 | od["b"] = 2 |
| 7 | od["c"] = 3 |
| 8 | print(list(od)) # ["a", "b", "c"] |
| 9 | |
| 10 | # Equality is order-sensitive |
| 11 | a = OrderedDict([("a", 1), ("b", 2)]) |
| 12 | b = OrderedDict([("b", 2), ("a", 1)]) |
| 13 | print(a == b) # False (order matters!) |
| 14 | |
| 15 | # Regular dict equality — order-insensitive |
| 16 | c = {"a": 1, "b": 2} |
| 17 | d = {"b": 2, "a": 1} |
| 18 | print(c == d) # True (3.7+ both ordered, but equality ignores order) |
| 19 | |
| 20 | # .move_to_end() — useful for LRU-like patterns |
| 21 | od = OrderedDict.fromkeys(["a", "b", "c", "d"]) |
| 22 | od.move_to_end("a") # "a" moved to end |
| 23 | print(list(od)) # ["b", "c", "d", "a"] |
| 24 | od.move_to_end("d", last=False) # "d" moved to front |
| 25 | print(list(od)) # ["d", "b", "c", "a"] |
| 26 | |
| 27 | # .popitem(last=True) — LIFO (default, same as dict 3.7+) |
| 28 | # .popitem(last=False) — FIFO (OrderedDict-only) |
| 29 | od.popitem(last=False) # removes ("d", None) — FIFO behavior |
ChainMap
A ChainMap groups multiple dicts into a single, updateable view. Lookups search each mapping in order, making it ideal for scoped configuration (e.g., user overrides layered on defaults).
| 1 | from collections import ChainMap |
| 2 | |
| 3 | defaults = {"theme": "dark", "lang": "en", "debug": False} |
| 4 | user = {"theme": "light", "lang": "fr"} |
| 5 | |
| 6 | # ChainMap — defaults serve as fallback |
| 7 | config = ChainMap(user, defaults) |
| 8 | print(config["theme"]) # "light" (from user) |
| 9 | print(config["debug"]) # False (from defaults) |
| 10 | print(config["lang"]) # "fr" (from user) |
| 11 | |
| 12 | # Mutations affect only the first mapping |
| 13 | config["debug"] = True |
| 14 | print(user) # {"theme": "light", "lang": "fr", "debug": True} |
| 15 | print(defaults) # unchanged |
| 16 | |
| 17 | # .new_child() — add another layer |
| 18 | cli_args = {"theme": "solarized"} |
| 19 | config = config.new_child(cli_args) |
| 20 | print(config["theme"]) # "solarized" |
| 21 | |
| 22 | # .parents — skip the first mapping |
| 23 | parent = config.parents |
| 24 | print(parent["theme"]) # "light" (from user) |
| 25 | |
| 26 | # .maps — access all underlying dicts |
| 27 | print(config.maps) # [{"theme": "solarized"}, {"theme": "light", ...}, ...] |
| 28 | |
| 29 | # Practical: environment config with fallbacks |
| 30 | import os |
| 31 | env = ChainMap(os.environ, {"HOME": "/home/default", "PATH": "/usr/bin"}) |
best practice
Dict comprehensions are one of Python's most elegant features. They combine iteration, transformation, and filtering into a single expression. Understanding their full power is essential for writing idiomatic Python.
| 1 | # Basic pattern: {key_expr: val_expr for item in iterable} |
| 2 | squares = {x: x ** 2 for x in range(5)} |
| 3 | # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16} |
| 4 | |
| 5 | # With filter condition |
| 6 | evens = {x: x ** 2 for x in range(10) if x % 2 == 0} |
| 7 | # {0: 0, 2: 4, 4: 16, 6: 36, 8: 64} |
| 8 | |
| 9 | # Transform values based on keys |
| 10 | data = {"a": 1, "b": 2, "c": 3} |
| 11 | transformed = {k: v * 10 if k != "b" else v for k, v in data.items()} |
| 12 | # {"a": 10, "b": 2, "c": 30} |
| 13 | |
| 14 | # Swap keys and values (values must be hashable) |
| 15 | inverted = {v: k for k, v in data.items()} |
| 16 | # {1: "a", 2: "b", 3: "c"} |
| 17 | |
| 18 | # Filter + transform from list |
| 19 | words = ["hello", "world", "hi", "python"] |
| 20 | lengths = {w: len(w) for w in words if len(w) > 3} |
| 21 | # {"hello": 5, "world": 5, "python": 6} |
| 22 | |
| 23 | # Enumerate into dict |
| 24 | indexed = {i: char for i, char in enumerate("abc")} |
| 25 | # {0: "a", 1: "b", 2: "c"} |
| 26 | |
| 27 | # Zip two lists into dict with comprehension control |
| 28 | keys = ["a", "b", "c"] |
| 29 | vals = [1, 2, 3] |
| 30 | d = {k: v for k, v in zip(keys, vals) if v > 1} |
| 31 | # {"b": 2, "c": 3} |
| 32 | |
| 33 | # Nested comprehension — flatten and transform |
| 34 | matrix = [[1, 2], [3, 4], [5, 6]] |
| 35 | indexed = {(i, j): val for i, row in enumerate(matrix) for j, val in enumerate(row)} |
| 36 | # {(0, 0): 1, (0, 1): 2, (1, 0): 3, (1, 1): 4, (2, 0): 5, (2, 1): 6} |
| 37 | |
| 38 | # Comprehension with conditional expression (not filter) |
| 39 | d = {k: ("even" if v % 2 == 0 else "odd") for k, v in {"a": 1, "b": 2}.items()} |
| 40 | # {"a": "odd", "b": "even"} |
| 41 | |
| 42 | # Group items by computed key |
| 43 | items = ["apple", "banana", "apricot", "blueberry", "cherry"] |
| 44 | grouped = {item[0]: [x for x in items if x.startswith(item[0])] for item in items} |
| 45 | # {"a": ["apple", "apricot"], "b": ["banana", "blueberry"], "c": ["cherry"]} |
| 46 | |
| 47 | # Dict from two iterables with defaultdict-like behavior via comprehension |
| 48 | from itertools import groupby |
| 49 | sorted_items = sorted(items, key=lambda x: x[0]) |
| 50 | grouped = {k: list(g) for k, g in groupby(sorted_items, key=lambda x: x[0])} |
| 51 | |
| 52 | # Conditional key inclusion |
| 53 | d = {f"key_{i}": i for i in range(10) if i % 2 == 0 if i > 2} |
| 54 | # {"key_4": 4, "key_6": 6, "key_8": 8} (both conditions must pass) |
| 55 | |
| 56 | # Using walrus operator (3.8+) in comprehensions |
| 57 | d = {x: y for x in range(5) if (y := x ** 2) > 5} |
| 58 | # {3: 9, 4: 16} |
info
Nested dicts are common for representing structured data like JSON objects, configuration trees, or hierarchical records. They require careful access and mutation patterns.
| 1 | # Nested dict literal |
| 2 | config = { |
| 3 | "database": { |
| 4 | "host": "localhost", |
| 5 | "port": 5432, |
| 6 | "credentials": { |
| 7 | "user": "admin", |
| 8 | "password": "secret", |
| 9 | }, |
| 10 | }, |
| 11 | "logging": { |
| 12 | "level": "INFO", |
| 13 | "file": "/var/log/app.log", |
| 14 | }, |
| 15 | } |
| 16 | |
| 17 | # Safe nested access |
| 18 | port = config.get("database", {}).get("port", 5432) |
| 19 | |
| 20 | # Nested assignment |
| 21 | config["database"]["credentials"]["password"] = "new_secret" |
| 22 | |
| 23 | # Check nested key existence |
| 24 | if "database" in config and "credentials" in config["database"]: |
| 25 | print(config["database"]["credentials"]["user"]) |
| 26 | |
| 27 | # Using try/except for nested access (EAFP style) |
| 28 | try: |
| 29 | user = config["database"]["credentials"]["user"] |
| 30 | except KeyError: |
| 31 | user = "default" |
| 32 | |
| 33 | # Flatten nested dict with dot notation |
| 34 | def flatten(d: dict, parent_key: str = "") -> dict: |
| 35 | items = [] |
| 36 | for k, v in d.items(): |
| 37 | new_key = f"{parent_key}.{k}" if parent_key else k |
| 38 | if isinstance(v, dict): |
| 39 | items.extend(flatten(v, new_key).items()) |
| 40 | else: |
| 41 | items.append((new_key, v)) |
| 42 | return dict(items) |
| 43 | |
| 44 | flat = flatten(config) |
| 45 | # {"database.host": "localhost", "database.port": 5432, ...} |
| 46 | |
| 47 | # Unflatten back |
| 48 | def unflatten(d: dict) -> dict: |
| 49 | result = {} |
| 50 | for key, val in d.items(): |
| 51 | parts = key.split(".") |
| 52 | current = result |
| 53 | for part in parts[:-1]: |
| 54 | current = current.setdefault(part, {}) |
| 55 | current[parts[-1]] = val |
| 56 | return result |
| 57 | |
| 58 | # Nested defaultdict for auto-vivification |
| 59 | from collections import defaultdict |
| 60 | nested = defaultdict(lambda: defaultdict(dict)) |
| 61 | nested["users"]["alice"]["role"] = "admin" |
| 62 | nested["users"]["bob"]["role"] = "viewer" |
warning
Dictionaries are implemented as hash tables, giving O(1) average time complexity for most operations. However, performance degrades with poor hash distribution, high load factors, or very large dicts.
| Operation | Average | Worst Case |
|---|---|---|
| Get / Set / Del | O(1) | O(n) |
| Membership (in) | O(1) | O(n) |
| Iteration (keys/values/items) | O(n) | O(n) |
| Copy (.copy()) | O(n) | O(n) |
| Merge (| or update) | O(len(other)) | O(len(other)) |
| 1 | # Dict vs list membership — dramatic difference |
| 2 | import timeit |
| 3 | |
| 4 | n = 100_000 |
| 5 | data_list = list(range(n)) |
| 6 | data_dict = {i: i for i in range(n)} |
| 7 | |
| 8 | # List membership: O(n) |
| 9 | # timeit.timeit(lambda: 99_999 in data_list, number=100) |
| 10 | # → ~0.3s |
| 11 | |
| 12 | # Dict membership: O(1) |
| 13 | # timeit.timeit(lambda: 99_999 in data_dict, number=100) |
| 14 | # → ~0.00001s (30,000x faster) |
| 15 | |
| 16 | # Memory — dicts use more memory than lists |
| 17 | # ~50-100 bytes per entry vs ~8 bytes per list item |
| 18 | # Trade-off: speed for memory |
| 19 | |
| 20 | # Hash collisions degrade performance |
| 21 | # Custom objects with poor __hash__ implementations |
| 22 | class BadKey: |
| 23 | def __hash__(self): |
| 24 | return 42 # all keys collide! |
| 25 | def __eq__(self, other): |
| 26 | return isinstance(other, BadKey) |
| 27 | |
| 28 | d = {BadKey(): i for i in range(1000)} |
| 29 | # Lookup degrades to O(n) — all keys in same bucket |
| 30 | |
| 31 | # Good practice: use immutable, built-in types as keys |
| 32 | # str, int, tuple, frozenset — all well-distributed |
info
Key Requirements — Hashable Types
Dict keys must be hashable — they need a stable __hash__() and a working __eq__(). Immutable built-in types (str, int, float, tuple of hashables, frozenset) are safe. Mutable types (list, dict, set) are not.
| 1 | # Valid keys — hashable types |
| 2 | d = {} |
| 3 | d["string"] = 1 # str |
| 4 | d[42] = 2 # int |
| 5 | d[3.14] = 3 # float |
| 6 | d[(1, 2)] = 4 # tuple (if elements are hashable) |
| 7 | d[frozenset({1})] = 5 # frozenset |
| 8 | d[True] = 6 # bool (bool is subclass of int) |
| 9 | d[None] = 7 # NoneType |
| 10 | |
| 11 | # Invalid — unhashable types |
| 12 | # d[[1, 2]] = 1 # TypeError: unhashable type: 'list' |
| 13 | # d[{"a": 1}] = 2 # TypeError: unhashable type: 'dict' |
| 14 | # d[{1, 2}] = 3 # TypeError: unhashable type: 'set' |
| 15 | |
| 16 | # Tuple with unhashable element → unhashable |
| 17 | # d[([1], 2)] = 1 # TypeError |
| 18 | |
| 19 | # Custom class — hashable by default (uses id) |
| 20 | class Point: |
| 21 | def __init__(self, x, y): |
| 22 | self.x, self.y = x, y |
| 23 | def __hash__(self): |
| 24 | return hash((self.x, self.y)) |
| 25 | def __eq__(self, other): |
| 26 | return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y) |
| 27 | |
| 28 | p = Point(3, 4) |
| 29 | d[p] = "origin" |
| 30 | print(d[Point(3, 4)]) # "origin" |
| 31 | |
| 32 | # Warning: if __hash__ depends on mutable fields, breakage follows |
| 33 | # Once inserted, do not mutate the key's hash-relevant attributes |
warning
The __missing__ Hook
Subclassing dict and defining __missing__ lets you customize what happens when a key is not found. defaultdict uses this internally.
| 1 | class CaseInsensitiveDict(dict): |
| 2 | """Dict with case-insensitive string keys.""" |
| 3 | def __missing__(self, key): |
| 4 | if isinstance(key, str): |
| 5 | for k, v in self.items(): |
| 6 | if isinstance(k, str) and k.lower() == key.lower(): |
| 7 | return v |
| 8 | raise KeyError(key) |
| 9 | |
| 10 | d = CaseInsensitiveDict({"Name": "Alice", "Role": "Admin"}) |
| 11 | print(d["name"]) # Alice (via __missing__) |
| 12 | print(d["NAME"]) # Alice |
| 13 | print(d["Role"]) # Admin |
| 14 | print(d["role"]) # Admin |
| 15 | |
| 16 | # The __missing__ hook is called only by d[key] and d.get(key) |
| 17 | # NOT by key in d, d.get(..., default), or iteration |
| 18 | |
| 19 | class DefaultDict(dict): |
| 20 | """Simplified version of collections.defaultdict.""" |
| 21 | def __init__(self, default_factory, *args, **kwargs): |
| 22 | super().__init__(*args, **kwargs) |
| 23 | self.default_factory = default_factory |
| 24 | |
| 25 | def __missing__(self, key): |
| 26 | if self.default_factory is not None: |
| 27 | self[key] = self.default_factory() |
| 28 | return self[key] |
| 29 | raise KeyError(key) |
| 30 | |
| 31 | d = DefaultDict(list) |
| 32 | d["users"].append("alice") # __missing__ creates list, then append |
| 33 | print(d) # {"users": ["alice"]} |
Dict as Switch/Case
Before Python 3.10 introduced match/case, dicts were (and still are) used to implement dispatch tables — a clean, extensible alternative to long if/elif chains.
| 1 | # Dict as dispatch table — no if/elif chains |
| 2 | def handle_create(args): return f"Creating {args}" |
| 3 | def handle_read(args): return f"Reading {args}" |
| 4 | def handle_update(args): return f"Updating {args}" |
| 5 | def handle_delete(args): return f"Deleting {args}" |
| 6 | |
| 7 | handlers = { |
| 8 | "create": handle_create, |
| 9 | "read": handle_read, |
| 10 | "update": handle_update, |
| 11 | "delete": handle_delete, |
| 12 | } |
| 13 | |
| 14 | def dispatch(command: str, args): |
| 15 | handler = handlers.get(command) |
| 16 | if handler is None: |
| 17 | return f"Unknown command: {command}" |
| 18 | return handler(args) |
| 19 | |
| 20 | print(dispatch("create", "user")) # "Creating user" |
| 21 | print(dispatch("delete", "file")) # "Deleting file" |
| 22 | |
| 23 | # Dynamic extension — add handlers at runtime |
| 24 | handlers["archive"] = lambda args: f"Archiving {args}" |
| 25 | |
| 26 | # Enum-based dispatch |
| 27 | from enum import Enum |
| 28 | |
| 29 | class Op(Enum): |
| 30 | ADD = "+" |
| 31 | SUB = "-" |
| 32 | MUL = "*" |
| 33 | DIV = "/" |
| 34 | |
| 35 | operations = { |
| 36 | Op.ADD: lambda a, b: a + b, |
| 37 | Op.SUB: lambda a, b: a - b, |
| 38 | Op.MUL: lambda a, b: a * b, |
| 39 | Op.DIV: lambda a, b: a / b if b != 0 else float("inf"), |
| 40 | } |
| 41 | |
| 42 | def calculate(op: Op, a: float, b: float) -> float: |
| 43 | func = operations.get(op) |
| 44 | if func is None: |
| 45 | raise ValueError(f"Unknown operation: {op}") |
| 46 | return func(a, b) |
| 47 | |
| 48 | # Class-based dispatch with method lookup |
| 49 | class Dispatcher: |
| 50 | def handle(self, action: str, *args): |
| 51 | method = getattr(self, f"do_{action}", None) |
| 52 | if method is None: |
| 53 | raise ValueError(f"No handler for {action}") |
| 54 | return method(*args) |
| 55 | |
| 56 | def do_add(self, a, b): return a + b |
| 57 | def do_sub(self, a, b): return a - b |
| 58 | |
| 59 | d = Dispatcher() |
| 60 | print(d.handle("add", 3, 4)) # 7 |
Dict Ordering (3.7+)
Since Python 3.7, dicts preserve insertion order as a language guarantee (CPython 3.6 had this as an implementation detail). This makes dicts predictable across all Python implementations.
| 1 | # Insertion order is preserved (Python 3.7+) |
| 2 | d = {} |
| 3 | d["z"] = 1 |
| 4 | d["a"] = 2 |
| 5 | d["m"] = 3 |
| 6 | d["b"] = 4 |
| 7 | print(list(d.keys())) # ["z", "a", "m", "b"] |
| 8 | |
| 9 | # Update does not change key position (unless key is new) |
| 10 | d["a"] = 99 |
| 11 | print(list(d.keys())) # ["z", "a", "m", "b"] (unchanged) |
| 12 | |
| 13 | # pop then re-insert moves to the end |
| 14 | val = d.pop("a") |
| 15 | d["a"] = 100 |
| 16 | print(list(d.keys())) # ["z", "m", "b", "a"] (reinserted at end) |
| 17 | |
| 18 | # delete in iteration — must use list(d) or list(d.keys()) |
| 19 | for k in list(d): |
| 20 | if k == "z": |
| 21 | del d[k] |
| 22 | |
| 23 | # Reversed iteration (Python 3.8+) |
| 24 | print(list(reversed(d))) # ["a", "b", "m"] |
| 25 | |
| 26 | # Ordering guarantee affects JSON serialization |
| 27 | import json |
| 28 | print(json.dumps({"z": 1, "a": 2, "m": 3})) |
| 29 | # {"z": 1, "a": 2, "m": 3} (order preserved) |
| 30 | |
| 31 | # Order equality vs value equality |
| 32 | print({"a": 1, "b": 2} == {"b": 2, "a": 1}) # True (value equality) |
| 33 | # Regular dict == ignores order, unlike OrderedDict |