Dictionaries

Dictionaries map hashable keys to values with fast lookup and, since Python 3.7, guaranteed insertion order — the backbone of structured data in Python.

A dictionary (dict) is a mutable mapping from unique, hashable keys to arbitrary values, implemented as a hash table for average O(1) lookup, insertion, and deletion. Since Python 3.7, dicts preserve insertion order as a language guarantee, not merely a CPython quirk, making them useful both as fast lookup tables and as ordered records.

Creating Dictionaries

Dicts are created with curly-brace literals of key: value pairs, the dict() constructor (from keyword arguments or an iterable of pairs), or a dict comprehension.

person = {"name": "Ana", "age": 30}
empty: dict[str, int] = {}
from_kwargs = dict(name="Bo", age=25)
from_pairs = dict([("a", 1), ("b", 2)])
squares = {n: n * n for n in range(5)}

Keys, Values, and Hashability

Keys must be hashable (and therefore effectively immutable in practice: strings, numbers, tuples of hashables, frozensets); values can be any object at all, including other dicts or lists.

valid = {("x", 0): "origin", 42: "answer", "name": "Ana"}
invalid = {[1, 2]: "bad"}   # TypeError: unhashable type: 'list'

Insertion Order

Since Python 3.7, dictionaries preserve the order in which keys were first inserted; this is a Python language guarantee, not an implementation detail. Re-assigning an existing key's value does not move it — its position stays where it was first inserted.

d: dict[str, int] = {}
d["b"] = 2
d["a"] = 1
d["c"] = 3
print(list(d))       # ['b', 'a', 'c'] -- insertion order, not sorted

d["b"] = 99            # updating an existing key doesn't move it
print(list(d))          # still ['b', 'a', 'c']

Lookup, Insertion, and Update

Square-bracket indexing reads a value by key (KeyError if missing) or, on the left of =, inserts a new key or updates an existing one's value.

d = {"a": 1, "b": 2}
d["a"]           # 1
d["c"]            # KeyError: 'c'
d["c"] = 3         # inserts a new key
d["a"] = 100        # updates existing key's value

Deletion

del removes a key entirely (KeyError if absent). pop(key) removes and returns a value, with an optional default to avoid the error. popitem() removes and returns the last-inserted (key, value) pair (LIFO order, guaranteed since 3.7).

d = {"a": 1, "b": 2, "c": 3}
del d["a"]              # {'b': 2, 'c': 3}
val = d.pop("b")          # 2 -- removes and returns
missing = d.pop("z", None)  # None -- default avoids KeyError
last = d.popitem()          # ('c', 3) -- most recently inserted

get() and Handling Missing Keys

get(key, default) looks up a key without raising if it's absent, returning None or a supplied default instead. This is the standard, safe way to read a key that might not exist.

d = {"a": 1}
d.get("a")          # 1
d.get("z")            # None -- no KeyError
d.get("z", 0)          # 0 -- explicit default
"z" in d                 # False -- membership test is the other safe option

setdefault()

setdefault(key, default) returns the existing value for a key if present, or inserts the key with the given default and returns that default — useful for grouping values without a separate existence check.

groups: dict[str, list[int]] = {}
data = [("evens", 2), ("odds", 1), ("evens", 4)]
for label, value in data:
    groups.setdefault(label, []).append(value)
print(groups)   # {'evens': [2, 4], 'odds': [1]}

keys(), values(), items()

These three methods return view objects — dynamic, lightweight windows onto the dict's current keys, values, and (key, value) pairs — not independent lists. Views automatically reflect subsequent changes to the dict.

d = {"a": 1, "b": 2}
for key in d.keys():        # or just: for key in d
    print(key)
for value in d.values():
    print(value)
for key, value in d.items():
    print(key, value)

keys_view = d.keys()
d["c"] = 3
print(list(keys_view))       # ['a', 'b', 'c'] -- view reflects the update

Dictionary Comprehensions

A dict comprehension builds a dict from an iterable in one expression, with the same for/if clauses as a list comprehension.

words = ["cat", "dog", "elephant"]
lengths = {w: len(w) for w in words}
long_only = {w: len(w) for w in words if len(w) > 3}

Missing Keys and defaultdict

A plain dict raises KeyError on a missing key by design — this is a language guarantee, not an oversight, since silently returning None can hide bugs. When you genuinely want missing keys to auto-populate, the standard library's collections.defaultdict (a related, separate concept) creates a default value on first access instead of raising.

from collections import defaultdict

counts: defaultdict[str, int] = defaultdict(int)
for letter in "banana":
    counts[letter] += 1     # no KeyError -- missing keys default to 0
print(dict(counts))          # {'b': 1, 'a': 3, 'n': 2}

Merging Dictionaries

update() merges another dict (or iterable of pairs) in place; the | operator (3.9+) produces a new merged dict, and |= merges in place. Later values win on key conflicts.

defaults = {"color": "blue", "size": "M"}
overrides = {"size": "L"}

merged = defaults | overrides       # {'color': 'blue', 'size': 'L'} -- new dict
defaults.update(overrides)            # mutates defaults in place
defaults |= {"color": "red"}           # in-place merge, 3.9+

Edge Cases

Mutating a dict's size (adding or deleting keys) while iterating directly over it raises RuntimeError; only value updates for existing keys are safe during iteration. Dict equality compares keys and values, ignoring order.

d = {"a": 1, "b": 2}
for k in d:
    d[k] = d[k] * 2       # OK -- updating value, not size
for k in list(d):
    if d[k] > 2:
        del d[k]           # OK -- iterating over a separate list snapshot

for k in d:
    d["new"] = 0           # RuntimeError: dictionary changed size during iteration

{"a": 1, "b": 2} == {"b": 2, "a": 1}   # True -- order doesn't affect equality

Complete Example: Word Frequency Counter

counts.get(word, 0) safely reads a running total (0 if unseen) before incrementing, avoiding a KeyError on the first occurrence of each word.

def word_frequency(text: str) -> dict[str, int]:
    counts: dict[str, int] = {}
    for word in text.lower().split():
        counts[word] = counts.get(word, 0) + 1
    return counts

freq = word_frequency("the cat sat on the mat the cat ran")
for word, n in sorted(freq.items(), key=lambda pair: -pair[1]):
    print(f"{word}: {n}")
# the: 3
# cat: 2
# sat: 1  ...

Mental Model and Quick Reference

Think of a dict as a hash table of key-hash-bucket pointing to a (key, value) slot, plus a side record of insertion order. Lookup hashes the key to jump nearly straight to its slot, rather than scanning — that's the source of its O(1) average speed.

Method            Effect                          Raises on missing key?
d[key]            read value                       yes (KeyError)
d.get(key, dflt)  read value or default             no
d[key] = v        insert or update                  no
del d[key]        remove key                         yes
d.pop(key, dflt)   remove & return, optional default   no (if default given)
d.setdefault(k,v)   get or insert-then-get              no
d.keys()/.values()/.items()  dynamic views              --

Key Takeaways

A dict maps hashable keys to arbitrary values, with average O(1) lookup, insertion, and deletion.

Insertion order is preserved as a Python language guarantee since 3.7, not just a CPython detail.

d[key] raises KeyError on a missing key; get() and setdefault() are the safe alternatives.

keys()/values()/items() return live views, not independent copies.

Changing a dict's size while iterating over it directly raises RuntimeError; iterate over a list(d) snapshot if you need to add or delete keys mid-loop.

collections.defaultdict is a related, separate standard-library tool for auto-populating missing keys.

value = d["maybe_missing"] # KeyError risk value = d.get("maybe_missing", default) get() with a default avoids crashing on a key that might not be present, unlike direct subscripting.

for k in d: del d[k] # RuntimeError for k in list(d): del d[k]

Deleting keys while iterating the dict directly changes its size mid-iteration; iterating over a separate list snapshot avoids that.

groups = {} for k, v in pairs: if k not in groups: groups[k] = [] groups[k].append(v) groups = {} for k, v in pairs: groups.setdefault(k, []).append(v) setdefault() collapses the existence check and insertion into one call, which is both shorter and avoids a double lookup.

Is dict insertion-order preservation a CPython implementation detail or a Python language guarantee?

It's a Python language guarantee as of Python 3.7 -- any conforming implementation, not just CPython, must preserve insertion order.

What's the difference between d[key] and d.get(key)?

d[key] raises KeyError if key is absent; d.get(key) returns None (or a supplied default) instead of raising, making it the safer choice when the key's presence isn't guaranteed.

What do keys(), values(), and items() actually return?

Dynamic view objects that stay linked to the dict -- they reflect later insertions or deletions rather than being frozen snapshots taken at call time.

Why does re-assigning an existing key's value not change its position in iteration order?

Insertion order tracks when a key was first added, not when its value was last updated; only deleting and re-adding a key would move it to the end.

What happens if you add a new key to a dict while iterating over it directly with a for loop?

RuntimeError: dictionary changed size during iteration -- changing the dict's size mid-iteration is unsafe; updating an existing key's value is fine, but adding or removing keys requires iterating over a separate snapshot like list(d).

What must be true of an object for it to be usable as a dict key?

It must be hashable, meaning it defines a stable hash() that doesn't change over its lifetime and an __eq__ consistent with that hash -- which is why strings, numbers, and tuples of hashables work but lists and dicts do not.

How does collections.defaultdict differ from a plain dict when a missing key is accessed?

A plain dict raises KeyError on a missing key; a defaultdict calls its factory function to create and insert a default value automatically, then returns that value, with no exception.

Write a function that inverts a dictionary (swaps keys and values), assuming all original values are unique and hashable.

def invert(d: dict[str, int]) -> dict[int, str]: return {value: key for key, value in d.items()} print(invert({"a": 1, "b": 2, "c": 3})) # {1: 'a', 2: 'b', 3: 'c'}

The comprehension iterates the original (key, value) pairs and reverses their roles; this only produces a valid dict if the original values are unique (and hashable), since duplicate values would collide as keys.

What does this print?

First {'a': 1, 'b': 2, 'c': 3} -- d2 is an alias of d, so mutating d2 changes d. Then it prints the same {'a': 1, 'b': 2, 'c': 3} again unchanged -- copy() made d3 an independent dict, so adding to d3 doesn't affect d.

Write a function that merges two dictionaries, summing values for keys that appear in both.

def merge_sum(a: dict[str, int], b: dict[str, int]) -> dict[str, int]: result = dict(a) for key, value in b.items(): result[key] = result.get(key, 0) + value return result print(merge_sum({"x": 1, "y": 2}, {"y": 3, "z": 4})) # {'x': 1, 'y': 5, 'z': 4} dict(a) makes an independent starting copy so the original a isn't mutated; get(key, 0) safely accumulates onto zero for keys only present in b.

d = {"a": 1, "b": 2}
d2 = d
d2["c"] = 3
print(d)
d3 = d.copy()
d3["d"] = 4
print(d)