backend / python core / tricky questions / 41_dict_merge_methods.md

Merging dicts: {a, b} vs a | b vs update vs ChainMap

3 min read source

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 | dict and ChainMap?” — 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 | b and a.update(b)?” — | allocates a new dict copying both; update mutates in place.

See stdlib/01_collections.md, 02_python_core/17_shallow_vs_deep_copy.md.