Dictionaries
Python's most useful data structure — key-value lookups, the .get pattern, and the collections helpers that turn hard problems into one-liners.
What you'll learn
- Safe access — d[key] versus .get, setdefault, and the in test
- Insertion-order guarantees and the | merge operator
- When to reach for defaultdict and Counter
- Dict comprehensions for lookups, filtering, and inverting a mapping
Before you start
If you have ever parsed a JSON response, you have used a dictionary. If you have grouped rows, indexed records by ID, or counted how often something occurs, you have used a dictionary. It is the hash map at the centre of nearly every Python program — and the reason it sits there is speed. A dictionary stores each value at a slot chosen by a numeric fingerprint of its key, so d["name"] does not search; it computes the fingerprint and jumps straight to the slot, in roughly constant time no matter how many keys the dictionary holds.
A key is hashed to a slot number, so d[“name”] jumps straight to its value — O(1), no scanning.
Creating and accessing
A dictionary maps keys to values. The interesting decision is what to do when a key you ask for is not there — and Python gives you two deliberately different answers:
user = {
"id": 42,
"name": "Aarav",
"email": "aarav@example.com",
"roles": ["admin", "editor"],
}
# Access by key. A missing key raises KeyError.
print(user["name"])
# Safer: .get returns None — or a default you supply — when the key is absent.
print(user.get("phone"))
print(user.get("phone", "n/a"))
# Membership test checks the keys.
print("email" in user)
print("phone" in user)
# Add, update, delete.
user["phone"] = "+91-9876543210"
del user["roles"]
print(user)
Aarav
None
n/a
True
False
{'id': 42, 'name': 'Aarav', 'email': 'aarav@example.com', 'phone': '+91-9876543210'}
Fix the two access patterns in your mind, because choosing between them is a small design decision you will make constantly. Use d[key] when the key must exist — a missing one then raises KeyError, and a loud failure is exactly what you want when absence signals a bug upstream. Use d.get(key, default) when the key is genuinely optional, and the quiet default is the right answer rather than a crash.
Building a dict — a few ways
You will most often write a dictionary as a literal, but several other constructors earn their place:
# From a list of (key, value) pairs — often what a CSV parser hands you.
pairs = [("name", "Aarav"), ("age", 28)]
profile = dict(pairs)
print(profile)
# From keyword arguments — keys must be valid identifiers.
opts = dict(timeout=30, retries=3)
print(opts)
# fromkeys — the same value for every key, handy for flags.
seen = dict.fromkeys(["alice", "bob", "chen"], False)
print(seen)
{'name': 'Aarav', 'age': 28}
{'timeout': 30, 'retries': 3}
{'alice': False, 'bob': False, 'chen': False}
setdefault — the “get or create” idiom
A recurring task is grouping items into buckets, and the naive version is littered with “is this key here yet?” checks. setdefault(key, default) collapses that: it returns the value at key, first inserting default if the key is missing.
rows = [
{"name": "Aarav", "dept": "eng"},
{"name": "Bina", "dept": "data"},
{"name": "Chen", "dept": "eng"},
{"name": "Dani", "dept": "ops"},
{"name": "Eli", "dept": "data"},
]
by_dept = {}
for r in rows:
# Returns the existing list, or creates an empty one and returns that.
by_dept.setdefault(r["dept"], []).append(r["name"])
print(by_dept)
{'eng': ['Aarav', 'Chen'], 'data': ['Bina', 'Eli'], 'ops': ['Dani']}
Reach for setdefault any time you aggregate into groups. In a moment we will meet defaultdict, which makes the very same pattern even cleaner.
Iterating — keys, values, items
Iterating a dictionary directly walks its keys. More often you want each key paired with its value, which is what .items() gives you:
user = {"id": 42, "name": "Aarav", "email": "aarav@example.com"}
# Plain iteration yields keys.
for key in user:
print(key)
print()
# .items() yields (key, value) pairs — the one you will use most.
for key, value in user.items():
print(f"{key:<6} -> {value}")
print()
# .keys() and .values() return live VIEWS, not snapshots.
print(list(user.keys()))
print(list(user.values()))
id
name
email
id -> 42
name -> Aarav
email -> aarav@example.com
['id', 'name', 'email']
[42, 'Aarav', 'aarav@example.com']
.items() is the standard way to walk a dictionary, and unpacking each pair into key, value reads cleanly. One subtlety worth knowing: .keys() and .values() return view objects — live windows onto the dictionary that reflect later changes — rather than fixed copies taken at the moment you called them.
Ordering — guaranteed since Python 3.7
Since Python 3.7, dictionaries preserve insertion order, and this is a language guarantee rather than a happy accident of the implementation:
config = {}
config["host"] = "localhost"
config["port"] = 5432
config["debug"] = True
list(config) == ["host", "port", "debug"] # always True in 3.7+
That guarantee is what lets you rely on order for display, for serialisation, and for the order-preserving dedup trick list(dict.fromkeys(items)) you met with sets.
The merge operator
Since Python 3.9, | merges two dictionaries, and on any key they share, the right-hand operand wins:
defaults = {"timeout": 30, "retries": 3, "verbose": False}
overrides = {"retries": 5, "debug": True}
merged = defaults | overrides
print(merged)
# In-place version with |=
config = defaults.copy()
config |= overrides
print(config)
{'timeout': 30, 'retries': 5, 'verbose': False, 'debug': True}
{'timeout': 30, 'retries': 5, 'verbose': False, 'debug': True}
Notice retries updated to 5 in place while debug was appended at the end — right-hand wins on conflicts, insertion order holds otherwise. This reads more clearly than the older {**a, **b} spread it replaces.
defaultdict — the default decided up front
defaultdict(factory) from the collections module is setdefault made automatic. You tell it how to build a missing value once, and every first access to a new key just works:
from collections import defaultdict
rows = [
("eng", "Aarav"),
("data", "Bina"),
("eng", "Chen"),
("ops", "Dani"),
("data", "Eli"),
]
by_dept = defaultdict(list)
for dept, name in rows:
by_dept[dept].append(name)
print(dict(by_dept)) # cast back to a plain dict for display
{'eng': ['Aarav', 'Chen'], 'data': ['Bina', 'Eli'], 'ops': ['Dani']}
The first time you touch a missing key, the factory — list here — is called and the result stored, so the if key not in d boilerplate disappears entirely.
Counter — frequencies in one line
This member of collections is good enough to deserve its own moment. Counter tallies how often each value appears, and then offers exactly the operations you want on a tally:
from collections import Counter
# The HTTP status codes from a day's access log.
log_codes = [200, 200, 200, 404, 500, 200, 301, 200, 404, 200, 200, 500, 404]
counts = Counter(log_codes)
print(counts)
# The three most common.
print(counts.most_common(3))
# A Counter is a dict — same access, but a missing key is 0, not KeyError.
print(counts[200])
print(counts[999])
# Counters even do arithmetic.
todays = Counter({"login": 50, "signup": 12, "purchase": 8})
yesterdays = Counter({"login": 40, "signup": 15, "purchase": 10})
print(todays - yesterdays) # subtraction drops zero and negative results
Counter({200: 7, 404: 3, 500: 2, 301: 1})
[(200, 7), (404, 3), (500, 2)]
7
0
Counter({'login': 10})
Two niceties show through that output. A Counter prints itself already sorted by descending count, and its subtraction keeps only the positive results — so todays - yesterdays cleanly reports the one metric that actually grew. Whenever someone asks “what are the top N things in this list?”, the answer is one import and most_common.
Dict comprehensions
Just as a list comprehension builds a list, a dict comprehension builds a dictionary — same idea, with key: value in front:
users = [
{"id": 1, "email": "a@x.com"},
{"id": 2, "email": "b@x.com"},
{"id": 3, "email": "c@x.com"},
]
# Build a lookup table keyed by id.
by_id = {u["id"]: u for u in users}
print(by_id[2])
# Filter while building.
prices = {"pen": 10, "notebook": 80, "laptop": 50000, "mouse": 200}
expensive = {k: v for k, v in prices.items() if v > 100}
print(expensive)
# Invert a mapping (when the values are unique).
code_to_status = {200: "OK", 404: "Not Found", 500: "Server Error"}
status_to_code = {v: k for k, v in code_to_status.items()}
print(status_to_code)
{'id': 2, 'email': 'b@x.com'}
{'laptop': 50000, 'mouse': 200}
{'OK': 200, 'Not Found': 404, 'Server Error': 500}
Building a lookup table — {record_key: record for record in records} — is the move you will reach for most: it turns a list you would have to scan into a dictionary you can index in O(1).
In one breath
- A dict is a hash map: key-to-value lookup in O(1), keys must be hashable.
d[key]raisesKeyErrorif missing;d.get(key, default)returns the default — choose by whether absence is a bug.- Dictionaries keep insertion order (guaranteed since 3.7);
|merges with the right side winning. defaultdict(factory)auto-creates missing values;Countertallies and ranks frequencies.- A dict comprehension
{k: v for ... in ...}builds lookups, filters, and inverts mappings.
Practice
Quick check
What’s next
You can now build the data structures Python programs are made of. Next is how to make decisions and repeat work — control flow, including the modern match/case statement.
Practice this in an interview
All questionscollections.Counter is the standard tool: it accepts any iterable and returns a dict-like object with counts. For a missing key it returns 0 rather than raising KeyError, which makes downstream arithmetic safe without extra guards.
CPython dicts are open-addressing hash tables. On lookup, Python calls __hash__ on the key to find a slot, then uses __eq__ to confirm the match. A valid dict key must be hashable — immutable by convention — and two objects that compare equal must have the same hash. Hash collisions are resolved by probing, which is why worst-case lookup degrades from O(1) to O(n).
collections.defaultdict eliminates the boilerplate of checking whether a key exists before appending, making grouping logic cleaner and less error-prone. It runs in O(n) and is the standard pattern before reaching for itertools.groupby or pandas groupby.
Choose a list when order matters and you need indexed access or duplicates. Choose a dict when you need to map keys to values and look up by key in O(1). Choose a set when you need uniqueness, fast membership testing, or set-algebra operations. Getting this choice wrong usually means either incorrect results (keeping duplicates when you needed uniqueness) or avoidable O(n) lookups.