Skip to content

Dictionaries Interview Questions & Answers

16 questions Updated 2026-06-18 Share:

Python interview questions on dict insertion ordering, get vs bracket access, setdefault, merging dicts, view objects, why keys must be hashable, and dict comprehensions.

Read the in-depth guidePython Dictionaries Explained — Ordering, Lookups, and Merging(opens in new tab)
16 of 16

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 ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel