Dictionaries
Key-value storage, the most-used data structure in real Python code — every method, iteration patterns, insertion ordering, merging, and defaultdict.
Dictionaries — The Data Structure You Will Use the Most
This module opens Phase 2: Core Data Structures & Logic. Phase 1 gave you the foundations — variables, types, operators, strings, control flow, loops, functions, lists, tuples and sets, and I/O formatting. Everything in Phase 2 builds directly on that: dictionaries rely on the mutability and hashability concepts from Module 02, comprehensions in the next module are a compact rewrite of the loops you learned in Module 06, and nested data structures a few modules from now are just dicts and lists containing more dicts and lists. Nothing here is new mechanics — it is the same mechanics, combined into shapes that look like real production data.
If you had to guess which single data structure appears most often in real Python codebases, the honest answer is the dictionary. JSON — the format nearly every web API speaks — maps directly onto Python dicts. Configuration files, database query results, function keyword arguments, cached values, request payloads, environment variables: all of it eventually becomes a dict in your running program. Lists are for "a bunch of things in order." Dictionaries are for "a value, looked up by a name" — and once you start noticing it, almost everything you model in a real application is a lookup by name.
employee = {
"name": "Priya Nair",
"role": "Backend Engineer",
"salary": 118000,
"remote": True,
}
print(employee["name"]) # "Priya Nair"
print(employee["salary"]) # 118000Each entry is a key: value pair. Keys are how you look values up — like an index, except instead of a position (0, 1, 2...) you use a meaningful name. Values can be anything: a string, a number, a list, even another dict. Keys are far more restricted, and that restriction is the subject of Part 02.
Three Ways to Build a Dict — And Why Keys Must Be Hashable
The curly-brace literal from Part 01 is the most common way to create a dict, but it is not the only one. The dict() constructor and dict.fromkeys() both come up in real code, each suited to a different situation.
employee = dict(name="Priya Nair", role="Backend Engineer", salary=118000)
# Identical to the literal form, but the keys are written as bare identifiers,
# not quoted strings — convenient when every key is a valid Python name.
# dict() also accepts a list of (key, value) tuple pairs:
pairs = [("a", 1), ("b", 2), ("c", 3)]
d = dict(pairs) # {"a": 1, "b": 2, "c": 3}# Initialise a dict where every key starts with the same value —
# genuinely common for counters and "seen" trackers
inventory_counts = dict.fromkeys(["apples", "bananas", "cherries"], 0)
print(inventory_counts)
# {"apples": 0, "bananas": 0, "cherries": 0}{k: [] for k in keys}.Why keys must be hashable
A dict is not a list dressed up with names — internally, it is a hash table. When you do employee["salary"], Python does not scan every key looking for a match. It runs "salary" through a hash function, uses the result to jump almost directly to the right storage slot, and confirms the key matches. This is the entire reason dict lookups are so fast — and it is also why dict keys have a hard restriction: a key must be hashable, meaning its hash value can never change over its lifetime.
Recall from Module 02 that every object is either mutable or immutable. Mutable objects — lists, dicts, sets — cannot be hashed at all, because their contents (and therefore their hash) could change after being used as a key, silently corrupting the hash table. This is the exact same rule you met in Module 09 when learning why sets can only contain immutable elements: sets and dict keys are built on the same underlying hash-table mechanism.
valid = {
"user_id": 42, # str key — hashable
(1, 2): "point", # tuple key — hashable, since (1, 2) is itself immutable
True: "yes", # bool key — hashable
}
invalid = {
[1, 2]: "point" # TypeError: unhashable type: 'list'
}Tuples deserve a special mention here: a tuple is hashable only if every element inside it is also hashable. (1, 2) is a fine dict key. (1, [2, 3]) is not, because it contains a list. This trips people up the first time they try to use a coordinate pair or composite key built from a mix of value types.
[] vs .get() — The Difference That Prevents Crashes
Square-bracket access (employee["salary"]) raises a KeyError the instant the key does not exist. That is fine when you are certain the key is present — but real-world data is rarely that certain, especially data coming from an external API or a user-supplied form where a field might simply be missing.
employee = {"name": "Priya Nair", "role": "Backend Engineer"}
print(employee["salary"])
# KeyError: 'salary'
print(employee.get("salary"))
# None — no crash, just a graceful "not found"
print(employee.get("salary", 0))
# 0 — .get()'s second argument is the default returned when the key is missing.get() for any key whose presence you are not 100% certain of. Reserve [] for cases where a missing key genuinely indicates a bug in your program and you want the loud failure. This mirrors the guard-clause philosophy from Module 05 — decide deliberately whether a missing value is an expected case to handle gracefully, or a real error that should surface immediately.The membership check — the in operator
To check whether a key exists without retrieving its value, use in — the same membership operator from Module 03's operators module, applied to a dict's keys.
employee = {"name": "Priya Nair", "role": "Backend Engineer"}
print("name" in employee) # True — checks keys
print("Priya Nair" in employee) # False — the VALUE "Priya Nair" is not a key
# To check values, be explicit:
print("Priya Nair" in employee.values()) # TrueEvery Method You Will Actually Reach For
A dictionary has a small set of methods, and unlike lists (which have dozens of situational methods), you will use nearly all of a dict's methods regularly. It is worth learning all of them properly rather than picking them up piecemeal.
employee = {"name": "Priya Nair", "role": "Backend Engineer", "salary": 118000}
print(employee.keys()) # dict_keys(['name', 'role', 'salary'])
print(employee.values()) # dict_values(['Priya Nair', 'Backend Engineer', 118000])
print(employee.items()) # dict_items([('name', 'Priya Nair'), ('role', 'Backend Engineer'), ('salary', 118000)])These three are not lists — they are "view" objects, which stay live if the underlying dict changes later (rare to rely on directly, but worth knowing so type(employee.keys()) not saying list doesn't surprise you). Wrap any of them in list() if you specifically need a real list.
employee = {"name": "Priya Nair", "role": "Backend Engineer"}
employee.update({"role": "Senior Backend Engineer", "salary": 135000})
print(employee)
# {"name": "Priya Nair", "role": "Senior Backend Engineer", "salary": 135000}
# "role" was overwritten (it already existed); "salary" was added (it didn't).employee = {"name": "Priya Nair", "role": "Backend Engineer", "salary": 118000}
salary = employee.pop("salary") # removes "salary", returns 118000
print(employee) # {"name": "Priya Nair", "role": "Backend Engineer"}
missing = employee.pop("bonus", 0) # key doesn't exist — returns the default, no crash
print(missing) # 0
last = employee.popitem() # removes and returns the LAST inserted (key, value) pair
print(last) # ("role", "Backend Engineer")counts = {}
# Without setdefault — the awkward way to "get or initialise"
if "apples" not in counts:
counts["apples"] = 0
counts["apples"] += 1
# With setdefault — same result, one line
counts.setdefault("apples", 0)
counts["apples"] += 1
print(counts) # {"apples": 2}.setdefault(key, default) returns the value for key if it exists; if it does not, it inserts key with default and then returns that default. It is a genuinely useful shortcut for building up grouped data — you will use it heavily once you reach the grouping patterns in Module 13 — though for the single most common case (grouping into lists), collections.defaultdict in Part 07 is usually the cleaner tool.
employee = {"name": "Priya Nair", "role": "Backend Engineer"}
copy = employee.copy() # a shallow copy — a new dict, same top-level keys/values
employee.clear() # empties the dict in place — {}
print(len(copy)) # 2 — len() works on dicts too, counting key/value pairscopy.deepcopy() from the standard library's copy module.Iterating a Dict — And Why .items() Is the Idiomatic Default
Looping over a dict directly iterates its keys — this surprises people coming from languages where iterating a map-like structure gives you entries by default.
employee = {"name": "Priya Nair", "role": "Backend Engineer", "salary": 118000}
for key in employee:
print(key)
# name
# role
# salaryTo get the value too, you could look it up inside the loop — but that means a second hash lookup on every iteration, purely to fetch something Python already had on hand a moment earlier. The idiomatic, and faster, approach is unpacking .items() directly into two loop variables.
# Works, but does a redundant lookup on every iteration
for key in employee:
print(key, employee[key])
# Idiomatic — unpacks (key, value) tuples directly, no extra lookup
for key, value in employee.items():
print(key, value)If you genuinely only need the values and never the keys, iterate .values() directly rather than .items() and discarding the key — it says exactly what you mean, and it is the pattern a reviewer will expect to see.
total_salary = sum(employee_dict["salary"] for employee_dict in team_members)
# If team_members were itself a dict of employee -> salary:
salaries = {"Priya": 118000, "Wei": 121000, "Alex": 109000}
total = sum(salaries.values())
print(total) # 348000Dict Ordering — Insertion Order Since Python 3.7
Since Python 3.7, dictionaries officially preserve insertion order — keys come back out in the same order you put them in, guaranteed by the language specification, not just as an implementation detail. If this seems unremarkable to you, it is worth knowing why it genuinely surprised experienced Python engineers when it landed.
Before 3.7 (and unofficially even in 3.6, where CPython's implementation happened to preserve order but the language spec did not guarantee it), dicts were explicitly unordered. Iterating the same dict twice could — in principle — give you keys in a different sequence, because the internal hash table made no promises about iteration order. Anyone who had written Python before 3.7 was trained to never rely on dict order for anything, and to reach for collections.OrderedDict whenever order genuinely mattered.
d = {}
d["z"] = 1
d["a"] = 2
d["m"] = 3
print(list(d.keys()))
# ['z', 'a', 'm'] — exactly insertion order, NOT alphabeticalcollections.OrderedDict still exists and is still used in modern code, but only for a few specific reasons now: it supports .move_to_end(), its equality check considers order (two regular dicts with the same pairs in different order are still equal; two OrderedDicts are not), and some codebases keep it for explicitness. For everyday code, a plain dict is order-preserving and is what you should reach for by default.One consequence worth internalising: since regular dicts preserve insertion order, they can now do double duty as an ordered "seen items" tracker or a simple ordered set-like structure in situations where you need uniqueness and order — something a plain set (from Module 09) cannot give you, since sets make no ordering promises at all.
Nested Dicts, Merging With | and **, and collections.defaultdict
Dict values can be anything, including other dicts — this is how real hierarchical data (a user profile with a nested address, a config file with nested sections) gets represented in Python. Module 13 goes much deeper into working with nested shapes; here is the basic mechanics.
user = {
"name": "Priya Nair",
"address": {
"city": "Denver",
"state": "CO",
"zip": "80202",
},
}
print(user["address"]["city"]) # "Denver"Merging dicts — three ways, two of them modern
defaults = {"timeout": 30, "retries": 3}
overrides = {"retries": 5, "verbose": True}
config = defaults | overrides
print(config)
# {"timeout": 30, "retries": 5, "verbose": True}
# Keys in "overrides" win when both dicts share a key. Neither original dict is modified.config = {**defaults, **overrides}
# Identical result to the | operator above — this pattern predates | (added in 3.5)
# and you will still see it constantly in real codebases.defaults.update(overrides)
# defaults is now itself changed to include overrides' keys.
# Use this specifically when mutating in place is what you want;
# use | or ** when you want a new dict and to leave both originals untouched.collections.defaultdict — eliminating "check, then initialise"
A recurring pattern: building up a dict where each key maps to a growing list or running count, and you constantly need to check "does this key exist yet?" before you can safely append or increment. collections.defaultdict removes that check entirely by supplying an automatic default for any key that does not yet exist.
orders_by_customer = {}
for customer, order in raw_orders:
orders_by_customer.setdefault(customer, []).append(order)from collections import defaultdict
orders_by_customer = defaultdict(list)
for customer, order in raw_orders:
orders_by_customer[customer].append(order)
# Accessing a missing key auto-creates it using the factory (list, here) — no setdefault needed.
word_counts = defaultdict(int)
for word in ["a", "b", "a", "c", "a"]:
word_counts[word] += 1
print(dict(word_counts)) # {"a": 3, "b": 1, "c": 1}defaultdict takes a factory function — something callable with no arguments that produces the default. list, int, set, and dict are the most common choices (int() returns 0, which is why it works for counting). Print a defaultdict and you will see it reported as defaultdict(<class 'list'>, {...}) — wrap it in dict(...) first if you want plain dict output.Why Dict Lookups Are O(1) — And When That Actually Matters
Looking a key up in a dict — employee["salary"] — takes roughly the same amount of time whether the dict has 5 entries or 5 million. This is described as O(1) ("constant time") lookup, and it is the single biggest practical reason to reach for a dict instead of a list when what you actually need is "find the thing matching this key."
user_ids = [101, 205, 309, ...] # a list of 100,000 IDs
if 88214 in user_ids:
...
# O(n) — in the worst case, Python checks every single element in order
user_id_set = {101, 205, 309, ...} # or a dict keyed by ID
if 88214 in user_id_set:
...
# O(1) — a single hash computation and slot lookup, regardless of sizeRecall Part 03's hash-table explanation: a dict does not search — it computes a key's hash, jumps to the corresponding storage slot, and confirms a match. Checking membership in a list, by contrast, means walking the list from the start until a match is found or the list runs out — the classic O(n) ("linear time") pattern, where the cost grows in direct proportion to how many items there are.
if some_id in a_big_list: inside a loop looks completely correct and passes testing on small sample data — then becomes painfully slow in production once the list grows to tens of thousands of entries, because the check silently became O(n) work repeated inside another loop, turning an intended O(n) algorithm into an accidental O(n²) one. If you find yourself repeatedly checking "is this value in this collection," and the collection does not need to preserve duplicates or a meaningful order, a set or a dict almost always beats a list at any real scale.A Denver Ride-Share Startup's Slow Endpoint
An engineer at a Denver ride-share startup owns an endpoint that, for each active driver, checks whether that driver has an open support ticket, and attaches a flag to the response if so. Support tickets are fetched once per request as a list of ticket dicts from a separate service.
def annotate_drivers(drivers, tickets):
for driver in drivers:
driver["has_open_ticket"] = any(
t["driver_id"] == driver["id"] and t["status"] == "open"
for t in tickets
)
return driversOn the local dev database — a few dozen drivers, a handful of tickets — this endpoint responds instantly. In production, with 4,000 active drivers and 1,200 open tickets across the metro area, the same endpoint takes over six seconds, well past the mobile app's timeout, and drivers start seeing blank screens during their shift.
What the profiler shows
For every one of 4,000 drivers, the code scans the entire tickets list looking for a match — exactly the O(n) membership scan from Part 08, repeated inside a loop over drivers. That is 4,000 × 1,200 = 4.8 million comparisons for what should be a handful of real lookups. The fix follows directly from Part 07's defaultdict pattern: pre-group the tickets by driver ID once, up front, turning every driver's check into a single O(1) dict lookup.
from collections import defaultdict
def annotate_drivers(drivers, tickets):
open_tickets_by_driver = defaultdict(bool)
for t in tickets:
if t["status"] == "open":
open_tickets_by_driver[t["driver_id"]] = True
for driver in drivers:
driver["has_open_ticket"] = open_tickets_by_driver[driver["id"]]
return driversThe endpoint drops from six seconds to under 40 milliseconds. Nothing about the business logic changed — the fix is purely about matching the data structure to the access pattern: build the lookup dict once (Part 07), then do O(1) lookups (Part 08) instead of repeated O(n) scans. This exact shape of bug — a linear scan hidden inside a loop, quietly turning into quadratic behaviour — is one of the most common real performance issues in production Python, and dictionaries are almost always the fix.
Four Misconceptions About Dictionaries
5 Interview Questions — With Complete Answers
Dictionary Mistakes Beginners Make Constantly
Errors You Will Hit With Dictionaries — And Exactly Why
🎯 Key Takeaways
- ✓A dict stores key/value pairs. Keys must be hashable — immutable all the way down — which is why lists can never be keys but tuples of hashable values can.
- ✓Use .get(key, default) when a missing key is expected and recoverable; use [] only when a missing key should crash loudly as a real bug.
- ✓The idiomatic iteration pattern is "for k, v in d.items():" — it avoids a redundant second lookup compared to indexing inside the loop.
- ✓Since Python 3.7, dicts guarantee insertion order as part of the language spec, not just as an implementation detail.
- ✓Merge dicts with | (Python 3.9+) or ** unpacking for a new merged dict; use .update() when mutating an existing dict in place is what you want.
- ✓collections.defaultdict eliminates manual "check, then initialise" logic — accessing a missing key auto-creates it using a factory function like list or int.
- ✓Dict (and set) lookups are O(1) via hashing; list membership checks are O(n). Repeated "in a_list" checks inside a loop are a classic source of accidental O(n²) production slowdowns.
- ✓.copy() is a shallow copy — nested mutable values are still shared with the original. Use copy.deepcopy() for full independence.
What comes next
Module 12 takes the loops that build lists and dicts one entry at a time and shows you the compact, Pythonic way to write the same logic — comprehensions — including exactly when they make code clearer and when they make it worse.
Module 12 → List, Dict and Set ComprehensionsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.