Yes. Since Python 3.7, dictionaries preserve insertion order as a language guarantee — iterating a dict yields keys in the order they were first added. (This was an implementation detail in CPython 3.6, then made official in 3.7.)
d = {}
d["b"] = 1
d["a"] = 2
d["c"] = 3
list(d) # ['b', 'a', 'c'] — insertion order, not sorted
d["b"] = 9 # updating a value does NOT change order
list(d) # ['b', 'a', 'c']
Note that updating an existing key keeps its original position; only the
first insertion fixes the order. Reassigning doesn't move it. Because ordering
is guaranteed, plain dict now covers most cases that once needed
OrderedDict.
d[key] raises KeyError if the key is missing. d.get(key, default)
returns a default (or None) instead of raising — a safe read.
d.setdefault(key, default) returns the existing value if present, but if
missing it inserts the default and returns it.
d = {"a": 1}
d["b"] # KeyError
d.get("b") # None — no error
d.get("b", 0) # 0 — supplied default
d.setdefault("a", 99) # 1 — already present, unchanged
d.setdefault("c", []).append(5) # inserts c=[], then appends -> {'c': [5]}
Use get for a safe lookup that doesn't mutate, and setdefault to
read-or-initialize in one step (handy for grouping). For heavy grouping work,
collections.defaultdict is usually cleaner.
The modern way is the | merge operator (Python 3.9+), which returns a new
dict; |= merges in place. Before 3.9, the idiom was {**a, **b}
unpacking, and dict.update() merges in place.
a = {"x": 1, "y": 2}
b = {"y": 9, "z": 3}
a | b # {'x': 1, 'y': 9, 'z': 3} — new dict (3.9+)
{**a, **b} # {'x': 1, 'y': 9, 'z': 3} — same, works pre-3.9
a.update(b) # mutates a in place -> {'x': 1, 'y': 9, 'z': 3}
In every approach the right-hand dict wins on key collisions (y becomes
9). Use | for a clean new dict on modern Python, {**a, **b} for
compatibility, and update()/|= when you want to mutate in place.
They return view objects — dynamic, read-only windows onto the dict that reflect changes live rather than copying the data. They're iterable and support set-like operations, but they're not lists.
d = {"a": 1, "b": 2}
keys = d.keys()
d["c"] = 3
list(keys) # ['a', 'b', 'c'] — view updated automatically!
keys[0] # TypeError — a view isn't indexable
list(d.keys()) # ['a', 'b', 'c'] — materialize when you need a list
d.keys() & {"a"} # {'a'} — keys views support set operations
Because a view is live, it's memory-cheap but you must list(...) it to
index or snapshot it. Also avoid mutating the dict's size while iterating a view
— that raises RuntimeError. Use views to iterate efficiently; copy to a list
when you need a stable, indexable sequence.
A dict is a hash table: it computes hash(key) to decide which bucket the
entry lives in, giving average O(1) insertion and lookup regardless of size.
For this to work, a key's hash must never change, so keys must be
hashable — effectively immutable.
d = {}
d[(1, 2)] = "ok" # tuple is immutable -> hashable
d[[1, 2]] = "no" # TypeError: unhashable type: 'list'
# O(1): lookup time doesn't grow with the dict's size
"x" in d # hashes "x", checks one bucket — not a full scan
If a mutable key could change after insertion, its hash would change and the entry would land in the wrong bucket — you'd never find it again. That's why lists, dicts, and sets can't be keys, but tuples and frozensets can. The hash-table design is exactly what makes membership tests near-instant.
A dict comprehension builds a dictionary in one expression using
{key: value for item in iterable}, optionally with a filtering if. It's the
concise, readable alternative to a for loop that calls d[k] = v.
squares = {n: n * n for n in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
prices = {"apple": 3, "pear": 0, "fig": 7}
in_stock = {k: v for k, v in prices.items() if v > 0}
# {'apple': 3, 'fig': 7}
inverted = {v: k for k, v in prices.items()} # swap keys/values
Like other comprehensions it has its own scope (no leaked loop variable) and is generally faster than the equivalent loop. Use it to transform, filter, or invert mappings clearly — but keep it readable rather than cramming in logic.
del d[k] removes the key but raises KeyError if absent. d.pop(k)
removes and returns the value, with an optional default to avoid the
error. d.popitem() removes and returns the last inserted pair (LIFO
since 3.7). d.clear() empties it.
d = {"a": 1, "b": 2}
d.pop("a") # 1
d.pop("x", None) # None, no error
d.popitem() # ('b', 2)
del d["missing"] # KeyError
Rule of thumb: prefer pop(k, default) when the key might be absent; use
del only when you know it exists.
No — adding or removing keys during iteration raises
RuntimeError: dictionary changed size during iteration. Iterate over a
snapshot (list(d) or list(d.items())) if you must mutate, or build a
new dict via comprehension.
d = {"a": 1, "b": 0, "c": 2}
for k in list(d): # snapshot of keys
if d[k] == 0:
del d[k]
# or: d = {k: v for k, v in d.items() if v != 0}
Rule of thumb: never resize a dict mid-iteration — iterate a copy of the keys or rebuild with a comprehension.
get(k, default) only reads; setdefault(k, default) reads and
inserts the default if missing, returning the stored value — handy for
accumulating into lists. The catch: the default is always evaluated, even
when the key exists, so an expensive default is wasteful.
groups = {}
for name, dept in people:
groups.setdefault(dept, []).append(name) # one-liner grouping
d.setdefault(k, expensive()) # expensive() runs even if k present!
Rule of thumb: setdefault for accumulate-into patterns; for heavy defaults
prefer defaultdict or an explicit if k not in d check.
Keys must be hashable, meaning a stable __hash__ for their lifetime.
Lists are mutable, so their contents (and any hash) could change,
breaking lookups — Python makes them unhashable. Tuples are immutable and
hashable only if all their elements are.
{("x", 1): "ok"} # fine, tuple of immutables
{[1, 2]: "v"} # TypeError: unhashable type: 'list'
{(1, [2]): "v"} # TypeError: tuple contains a list
Rule of thumb: use immutable values (tuples, frozensets, strings, numbers) as keys; convert mutable containers to tuples/frozensets first.
dict.fromkeys(keys, value) builds a dict mapping each key to the same
value. The trap: if that value is mutable (a list), all keys share one
object — mutating via one key affects all. Use a comprehension for
per-key fresh values.
dict.fromkeys("abc", 0) # {'a': 0, 'b': 0, 'c': 0} fine (immutable)
d = dict.fromkeys("ab", []) # both share ONE list
d["a"].append(1) # {'a': [1], 'b': [1]} !
d = {k: [] for k in "ab"} # correct: independent lists
Rule of thumb: fromkeys is safe for immutable defaults; use a dict
comprehension whenever the default is mutable.
A dict is an open-addressing hash table. The key's hash picks a slot; on a collision it probes other slots (a perturbation sequence) until it finds the key or an empty slot. It stores the full hash to compare quickly and resizes/rehashes when it gets ~2/3 full to keep operations near O(1).
hash(key) -> slot index -> collision? -> probe next slots -> place/find
Rule of thumb: average lookup/insert is O(1); a well-behaved __hash__ that
spreads values keeps it that way (bad hashes degrade toward O(n)).
Yes — keys(), values(), and items() return dynamic views that
reflect later changes to the dict, rather than static snapshots. They're also
set-like for keys/items, supporting &, |, - for comparing dicts.
d = {"a": 1}
ks = d.keys()
d["b"] = 2
list(ks) # ['a', 'b'] -> view updated
{"a", "x"} & d.keys() # {'a'} -> set operations on views
Rule of thumb: views stay in sync with the dict; materialize with list()
if you need a frozen snapshot.
Since Python 3.9, d1 | d2 returns a new merged dict (right side
wins on key conflicts), and d1 |= d2 updates d1 in place (like
update). They're the modern, readable alternative to {**d1, **d2}.
a, b = {"x": 1, "y": 2}, {"y": 9, "z": 3}
a | b # {'x': 1, 'y': 9, 'z': 3} new dict
a |= b # a becomes {'x': 1, 'y': 9, 'z': 3}
Rule of thumb: on 3.9+ use |/|= for clarity; {**a, **b} still works and
is needed on older versions.
All three count, with rising convenience. d.get(k, 0) + 1 works on a
plain dict. defaultdict(int) drops the get boilerplate. Counter
adds counting-specific tools (most_common, arithmetic, +/-) and can
count an iterable in one call.
from collections import Counter, defaultdict
Counter(text) # one-shot, richest API
d = defaultdict(int)
for c in text: d[c] += 1 # simple, no extra import beyond collections
Rule of thumb: use Counter when you want ranking/arithmetic, defaultdict(int)
for plain accumulation, and get only for tiny ad-hoc counts.
It uses both __hash__ and __eq__: keys land in the same bucket if
their hashes match, then are confirmed equal with ==. So equal objects
must have equal hashes (the hash/eq contract). Break it and lookups fail —
1, 1.0, and True collide intentionally because they're equal and
hash-equal.
d = {1: "int", True: "bool"} # True == 1 and hash(True)==hash(1)
d # {1: 'bool'} -> same key, overwritten
Rule of thumb: any custom key class must implement __hash__ and __eq__
consistently, or it will misbehave as a dict key.
More Data Structures interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.