Merging dicts: {**a, **b} vs a | b vs update vs ChainMap
The four ways and their differences
| Method | Mutates a? |
Returns | Python version | Notes |
|---|---|---|---|---|
{**a, **b} |
no | new dict | 3.5+ | last-wins on collision |
a | b |
no | new dict | 3.9+ | last-wins; equivalent to {**a, **b} |
a |= b |
yes | a |
3.9+ | in-place merge; last-wins |
a.update(b) |
yes | None |
always | in-place; last-wins |
ChainMap(a, b) |
no (separate view) | view | always | first-wins; layered lookup |
Minimal repros
a = {"x": 1, "y": 2}
b = {"y": 99, "z": 3}
# 1. ** unpacking — new dict
{**a, **b} # {'x': 1, 'y': 99, 'z': 3}
# 2. | operator — new dict (3.9+)
a | b # {'x': 1, 'y': 99, 'z': 3}
# 3. |= operator — in-place (3.9+)
c = a.copy()
c |= b
# c is now {'x': 1, 'y': 99, 'z': 3}
# 4. .update() — in-place
c = a.copy()
c.update(b) # returns None
# c is now {'x': 1, 'y': 99, 'z': 3}
# 5. ChainMap — layered view
from collections import ChainMap
cm = ChainMap(a, b)
cm['y'] # 2 ← FIRST wins (a's value), not last
list(cm) # ['x', 'y', 'z'] — but iteration order is dict-by-dict
dict(cm) # {'z': 3, 'y': 2, 'x': 1} ← FIRST wins again
When each wins
a | b — modern, readable. Best for “give me a merged copy.”
config = defaults | user_overrides
{**a, **b} — works on older Python (3.5+) and accepts more than two:
{**defaults, **environment, **user_overrides}
Also lets you add/override on the fly:
{**user, "id": str(user["id"])} # cast id to string
{**defaults, "verbose": True}
a |= b / a.update(b) — when you have a mutable target you want to grow:
all_users.update(batch_users)
all_users |= batch_users # equivalent
Use |= for new code; update works everywhere.
ChainMap — when you want a layered view without merging. Lookups walk the chain; modifications affect only the first map.
from collections import ChainMap
defaults = {"timeout": 30, "retries": 3}
overrides = {}
config = ChainMap(overrides, defaults)
config['timeout'] # 30 — falls through to defaults
config['timeout'] = 60 # writes to overrides only
config['timeout'] # 60
defaults['timeout'] # 30 — still untouched
overrides # {'timeout': 60}
Useful for:
- Scope chains (e.g., locals → enclosing → globals → builtins simulation).
- Cascading configuration (CLI flags → env vars → file → defaults).
- Temporarily overlaying settings without copying.
ChainMap.new_child() adds a new top layer for a temporary scope:
with config.new_child({"debug": True}) as scoped:
... # scoped sees debug=True; underlying config unchanged
First-wins vs last-wins — a real bug
defaults = {"timeout": 30}
user = {"timeout": 60}
# Probably what you want:
final = defaults | user # {'timeout': 60} last-wins, user override
# ChainMap goes the OTHER way:
cm = ChainMap(defaults, user)
cm['timeout'] # 30 first-wins
When using ChainMap for “user overrides defaults,” put the overrides first:
cm = ChainMap(user, defaults) # user wins
cm['timeout'] # 60
Easy to reverse and ship a bug. Always think about which map should win.
Performance
| Operation | Cost |
|---|---|
a | b / {**a, **b} |
O(n + m) — copies all keys |
a.update(b) / a |= b |
O(m) — visits only b’s keys |
ChainMap(a, b) lookup |
O(k) where k is # of maps in chain |
For small dicts, all are fast. For merging 100k+ entries:
- Want a copy →
|. - Have a target to extend →
update. - Don’t actually need a merge →
ChainMap(zero copy).
Interview angle
- Q: “How do you merge two dicts in Python?” —
a | b(3.9+),{**a, **b}(older),a.update(b)(in-place). - Q: “Difference between
dict | dictandChainMap?” — merge produces a new dict (last-wins); ChainMap is a layered view (first-wins). - Follow-up: “When would you use
ChainMap?” — cascading configuration, scope simulation, temporary overlays. - Follow-up: “What’s the perf difference between
a | banda.update(b)?” —|allocates a new dict copying both;updatemutates in place.
See stdlib/01_collections.md, 02_python_core/17_shallow_vs_deep_copy.md.