The Mental Model: Names to Objects
A dictionary is a mapping — it associates keys with values. But understanding how it does this reveals a lot about Python itself.
person = {"name": "Alice", "age": 30}
Dictionaries aren't just a data structure — the language itself runs on them. When you create a variable, Python stores it in a dictionary (__dict__). When you access an attribute, Python looks it up in a dictionary. Understanding dicts means understanding Python's internals.
Key Insight #1: Only Hashable Objects Can Be Keys
This trips up many developers coming from other languages:
my_dict = {[1, 2, 3]: "value"} # TypeError: unhashable type: 'list'
my_dict = {(1, 2, 3): "value"} # Tuples are hashable
Why? Dictionaries are hash tables. Python computes a hash of the key to determine where to store the value. Mutable objects like lists can change, which would break the hash table's invariants.
An object is hashable if it has a __hash__() method returning a consistent value, can be compared via __eq__(), and objects that compare equal have the same hash.
str, int, float, tuple frozenset, None
list, dict, set
Key Insight #2: Insertion Order Preserved (Python 3.7+)
A significant change that developers from older Python versions might not expect:
d = {} d["first"] = 1 d["second"] = 2 d["third"] = 3 for key in d: print(key) # Output: first, second, third (guaranteed)
Since Python 3.7, iteration order matches insertion order. This is a language guarantee, not an implementation detail. However, dicts are still optimized for key-based lookup, not positional access.
Key Insight #3: get() vs Square Bracket Access
This is where subtle bugs often hide:
config = {"debug": False, "timeout": 30} # Square brackets raise KeyError if key missing value = config["missing_key"] # KeyError: 'missing_key' # get() returns None (or a default) if key missing value = config.get("missing_key") # Returns None value = config.get("missing_key", 100) # Returns 100
The Subtlety with Falsy Defaults
config = {"debug": False} if config.get("debug"): print("Debug mode") # Never prints! False is falsy
config = {"debug": False} if "debug" in config and config["debug"]: print("Debug mode")
MISSING = object() value = config.get("nonexistent", MISSING) if value is MISSING: print("Key was not present")
Key Insight #4: setdefault() and defaultdict
A common pattern is "get the value, or create a default if missing":
groups = {} for item in items: key = get_category(item) if key not in groups: groups[key] = [] groups[key].append(item)
groups = {} for item in items: key = get_category(item) groups.setdefault(key, []).append(item)
from collections import defaultdict groups = defaultdict(list) for item in items: groups[get_category(item)].append(item)
It creates entries on any access, not just assignment:
from collections import defaultdict d = defaultdict(int) print(d["missing"]) # Prints 0, but also creates the key! print(dict(d)) # {'missing': 0} — the key now exists # This creates the key even if you're just checking! if d["maybe_missing"]: pass
Key Insight #5: Dictionary Views Are Live
When you call .keys(), .values(), or .items(), you don't get a copy — you get a view that reflects changes:
d = {"a": 1, "b": 2} keys = d.keys() print(list(keys)) # ['a', 'b'] d["c"] = 3 print(list(keys)) # ['a', 'b', 'c'] — the view updated!
You can't modify a dictionary while iterating over it — this raises RuntimeError.
d = {"a": 1, "b": 2, "c": 3} for key in d: if d[key] < 2: del d[key] # RuntimeError!
d = {"a": 1, "b": 2, "c": 3} for key in list(d.keys()): if d[key] < 2: del d[key] # Works fine
Key Insight #6: Dictionary Comprehensions
Just like list comprehensions, but for dicts:
# Basic syntax squares = {x: x**2 for x in range(5)} # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16} # Inverting a dictionary original = {"a": 1, "b": 2, "c": 3} inverted = {v: k for k, v in original.items()} # {1: 'a', 2: 'b', 3: 'c'} # Filtering scores = {"alice": 85, "bob": 62, "charlie": 91} passed = {name: score for name, score in scores.items() if score >= 70} # {'alice': 85, 'charlie': 91}
Key Insight #7: Merge Operators (Python 3.9+)
Python 3.9 introduced | and |= for dictionaries:
defaults = {"color": "blue", "size": "medium"} overrides = {"size": "large", "weight": "heavy"} # Merge (creates new dict, right side wins on conflicts) combined = defaults | overrides # {'color': 'blue', 'size': 'large', 'weight': 'heavy'} # Update in place defaults |= overrides # defaults is now {'color': 'blue', 'size': 'large', 'weight': 'heavy'}
# Unpacking (still works in 3.9+) combined = {**defaults, **overrides} # In-place modification defaults.update(overrides)
Key Insight #8: Identity vs Equality
A subtle source of bugs with dictionary references and copies:
a = {"x": [1, 2, 3]} b = a # b is the SAME object as a b["x"].append(4) print(a) # {'x': [1, 2, 3, 4]} — a changed too!
Shallow Copy vs Deep Copy
c = a.copy() # or dict(a) or {**a} c["x"].append(5) print(a) # a changed! Still linked
import copy d = copy.deepcopy(a) d["x"].append(6) print(a) # a unchanged!
Key Insight #9: Common Patterns and Idioms
Counting Occurrences
from collections import Counter words = ["apple", "banana", "apple", "cherry", "banana", "apple"] counts = Counter(words) # Counter({'apple': 3, 'banana': 2, 'cherry': 1}) counts.most_common(2) # [('apple', 3), ('banana', 2)]
Safe Nested Access
data = {"user": {"profile": {"name": "Alice"}}} # Dangerous — any missing level raises KeyError name = data["user"]["profile"]["name"] # Safe chained get name = data.get("user", {}).get("profile", {}).get("name")
def deep_get(d, *keys, default=None): for key in keys: if isinstance(d, dict): d = d.get(key, default) else: return default return d name = deep_get(data, "user", "profile", "name", default="Unknown")
Dicts as Switch/Case (Pre-3.10)
def handle_add(x, y): return x + y def handle_sub(x, y): return x - y operations = { "add": handle_add, "sub": handle_sub, } result = operations.get(op_name, lambda x, y: None)(a, b)
Performance Characteristics
| Operation | Average Case | Notes |
|---|---|---|
d[key] | O(1) | Hash table lookup |
d[key] = value | O(1) | Amortized |
del d[key] | O(1) | |
key in d | O(1) | Very fast! |
len(d) | O(1) | |
| Iteration | O(n) | |
d.copy() | O(n) | Shallow copy |
Checking x in my_list is O(n), but x in my_dict is O(1). For large datasets, this difference is enormous.
Summary: What Makes Python Dicts Unique
| Concept | Key Takeaway |
|---|---|
| Central to Python | Namespaces, object attributes, and class definitions all use dicts internally. |
| Insertion Order | Preserved since Python 3.7 — a guarantee other languages' hash maps don't provide. |
| Hashable Keys | Only immutable types (str, int, tuple, etc.) can be keys. Lists and dicts cannot. |
get() vs [] | Use get() for safe access. Watch out for falsy values vs missing keys. |
defaultdict | Cleanest pattern for grouping, but beware — it creates keys on any access. |
| Live Views | .keys(), .values(), .items() are live views, not copies. |
| Merge Operators | | and |= (3.9+) make combining dicts elegant. |
| Copy Semantics | .copy() is shallow. Use copy.deepcopy() for nested structures. |