The Story That Explains Dictionaries
A Python dictionary is that card catalog. You give it a key (the book title, or a person's name, or a coordinate) and it hands you back the value instantly — no scanning, no counting. The secret behind that magic is a mathematical trick called hashing: Python turns your key into a number that tells it exactly which "drawer" the value is stored in.
Dictionaries are Python's most powerful built-in data type. They power language servers, JSON, class attributes, function arguments, database records, caches — practically every non-trivial Python program uses at least one. Understand dictionaries, and you understand Python.
A dictionary (dict) is Python's built-in mapping type — a collection of key-value pairs where each key is unique. You look things up by key, not by position, and lookups are O(1) constant time: it takes the same time to find a value in a dict of 1,000 items as in a dict of 10 million.
Keys and Values — The Basic Structure
Every dictionary entry is a key-value pair: two objects glued together with a colon :. The key is what you ask for; the value is what you get back. Multiple pairs sit inside curly braces {}, separated by commas.
# ─── FIRST DICTIONARY ────────────────────────────────────
user = {
"name": "Alice",
"age": 30,
"city": "NYC"
}
# Look up by key
print(user["name"]) # Alice
print(user["age"]) # 30
# Keys can be different types
mixed = {
"name": "Bob", # str key
42: "answer", # int key
(1, 2): "point", # tuple key — allowed!
True: "yes" # bool key
}
# Values can be ANYTHING — including lists, dicts, functions
inventory = {
"apples": 12, # int value
"scores": [88, 92, 75], # list value
"config": {"tax": 0.15}, # dict value
"active": True # bool value
}
print(inventory["scores"]) # [88, 92, 75]
print(inventory["config"]["tax"]) # 0.15 — nested access!
Dictionaries remember the order you inserted keys. When you loop over a dict, you get the keys in the order they were added. This wasn't guaranteed in Python 3.6 and earlier, so old tutorials may claim dicts are "unordered". They aren't — not anymore.
Creating Dictionaries — Five Ways
# ─── 1. LITERAL SYNTAX (most common) ────────────────────
empty = {}
prices = {"apple": 1.2, "bread": 3.5, "milk": 2.8}
# ─── 2. dict() CONSTRUCTOR — keyword args ───────────────
user = dict(name="Alice", age=30, city="NYC")
# NOTE: keys become strings automatically — but this only works
# for string keys that are also valid Python identifiers.
# ─── 3. FROM A LIST OF (key, value) TUPLES ──────────────
pairs = [("a", 1), ("b", 2), ("c", 3)]
d = dict(pairs) # {'a': 1, 'b': 2, 'c': 3}
# ─── 4. dict.fromkeys() — same value for many keys ──────
zeroed = dict.fromkeys(["x", "y", "z"], 0)
print(zeroed) # {'x': 0, 'y': 0, 'z': 0}
# ─── 5. DICT COMPREHENSION ──────────────────────────────
squares = {n: n**2 for n in range(1, 6)}
print(squares) # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# ─── EMPTY DICT vs EMPTY SET — SNEAKY! ──────────────────
print(type({})) # dict — empty braces are a DICT
print(type(set())) # set — use set() for empty set!
What Is Hashing? — The Core Idea
That's hashing. A hash function takes any object (your coat = a Python key like "Alice") and produces a fixed-size number (the ticket = a hash value). Python uses that number to calculate exactly where the value lives in memory. No searching, no scanning — just arithmetic.
A hash function is a mathematical function that turns any input into a fixed-size integer called a hash. Python has one built in: hash(). It works on any immutable object.
# ─── SEE THE HASH FUNCTION IN ACTION ────────────────────
print(hash("Alice")) # e.g. 8398423712 (varies per session)
print(hash(42)) # 42 (small ints hash to themselves)
print(hash(3.14)) # 322818021289917443
print(hash((1, 2, 3))) # 529344067295497451 (tuples hash!)
# ─── DETERMINISTIC — same key ALWAYS same hash ──────────
print(hash("Alice") == hash("Alice")) # True
# ─── DIFFERENT KEYS → USUALLY DIFFERENT HASHES ──────────
print(hash("Alice") == hash("Bob")) # False (almost always)
# ─── MUTABLE OBJECTS CANNOT BE HASHED ───────────────────
# hash([1, 2, 3]) → TypeError: unhashable type: 'list'
# hash({"a": 1}) → TypeError: unhashable type: 'dict'
# hash({1, 2, 3}) → TypeError: unhashable type: 'set'
How Dictionaries Are Stored in Memory
A Python dict is a hash table: an array of "buckets" (slots) where entries live. To store d["Alice"] = 30, Python:
The Hash Table in Memory
What About Collisions?
What if two different keys hash to the same bucket index? Python handles this with a strategy called open addressing with probing: it looks at the next bucket, and the next, until it finds an empty one. This works well because the table stays mostly empty — Python resizes it (usually doubling its capacity) once it becomes about two-thirds full.
Python keeps hash tables at least 1/3 empty on purpose. A packed table has many collisions, which turns O(1) lookups into O(n) scans. A sparse table wastes a little memory but keeps lookups genuinely constant time. When occupancy climbs above about 2/3, Python allocates a bigger table and re-hashes all existing entries.
Rules for Valid Dictionary Keys
Not every Python object can be a dictionary key. The rule: a key must be hashable. In practice, that means it must be immutable — because if the object could change, its hash would change too, and Python would lose track of where the entry lives.
| Type | Example |
|---|---|
| int | 42 |
| float | 3.14 |
| str | "name" |
| bool | True, False |
| tuple (of hashables!) | (1, 2) |
| frozenset | frozenset({1, 2}) |
| None | None |
| Type | Example |
|---|---|
| list | [1, 2, 3] |
| dict | {"a": 1} |
| set | {1, 2, 3} |
| tuple w/ mutable inside | (1, [2, 3]) |
| bytearray | bytearray(b"hi") |
| Any mutable object | — TypeError |
# ─── VALID KEYS ─────────────────────────────────────────
d = {}
d[42] = "int key"
d["name"] = "str key"
d[3.14] = "float key"
d[(1, 2)] = "tuple key" # great for coordinates!
d[None] = "None as key"
print(d[(1, 2)]) # tuple key
# ─── INVALID KEYS ───────────────────────────────────────
# d[[1, 2]] = "x" → TypeError: unhashable type: 'list'
# d[{"a": 1}] = "x" → TypeError: unhashable type: 'dict'
# d[{1, 2}] = "x" → TypeError: unhashable type: 'set'
# d[(1, [2])] = "x" → TypeError (tuple contains a list!)
# ─── COMMON PATTERN: TUPLE KEY FOR 2D LOOKUP ────────────
grid = {}
grid[(0, 0)] = "start"
grid[(1, 2)] = "tree"
grid[(3, 4)] = "goal"
print(grid[(1, 2)]) # tree
Accessing and Modifying Values
Reading — Three Ways to Access a Value
user = {"name": "Alice", "age": 30, "city": "NYC"}
# ─── 1. SQUARE BRACKETS — crashes if key missing ────────
print(user["name"]) # Alice
# print(user["email"]) → KeyError!
# ─── 2. .get() — safe, returns None (or default) ────────
print(user.get("name")) # Alice
print(user.get("email")) # None (no crash!)
print(user.get("email", "n/a")) # n/a (custom default)
# ─── 3. 'in' — check first, then read ───────────────────
if "email" in user:
print(user["email"])
else:
print("no email on file")
Writing — Add, Update, Delete
# ─── ADD OR UPDATE (same syntax) ────────────────────────
user = {"name": "Alice", "age": 30}
user["city"] = "NYC" # add a new key
user["age"] = 31 # update an existing key (birthday!)
print(user) # {'name': 'Alice', 'age': 31, 'city': 'NYC'}
# ─── DELETE — del or pop ────────────────────────────────
del user["city"] # remove key (crashes if missing)
age = user.pop("age") # remove AND return the value
print(age, user) # 31 {'name': 'Alice'}
# .pop() with default — safe removal
email = user.pop("email", None) # None (no error even if missing)
Dictionary Methods — The Complete Toolkit
View Methods — .keys(), .values(), .items()
scores = {"Alice": 92, "Bob": 78, "Carol": 85}
# ─── .keys() — all the keys ─────────────────────────────
print(scores.keys()) # dict_keys(['Alice', 'Bob', 'Carol'])
print(list(scores.keys())) # ['Alice', 'Bob', 'Carol']
# ─── .values() — all the values ─────────────────────────
print(scores.values()) # dict_values([92, 78, 85])
print(sum(scores.values())) # 255
# ─── .items() — all (key, value) pairs as tuples ────────
print(scores.items()) # dict_items([('Alice', 92), ('Bob', 78), ...])
# THE KEY INSIGHT: these are LIVE VIEWS, not copies!
keys_view = scores.keys()
scores["Dan"] = 88 # mutate the dict
print(keys_view) # view reflects the change automatically!
Mutators — Change the Dictionary
scores = {"Alice": 92, "Bob": 78}
# ─── .update() — merge another dict (or iterable) ──────
scores.update({"Carol": 85, "Dan": 73})
print(scores) # {'Alice': 92, 'Bob': 78, 'Carol': 85, 'Dan': 73}
# Update also OVERWRITES existing keys
scores.update({"Alice": 99}) # bumps Alice's score
print(scores["Alice"]) # 99
# ─── .pop(key) — remove & return value ──────────────────
val = scores.pop("Bob") # 78
# scores.pop("Missing") → KeyError
val = scores.pop("Missing", -1) # -1 (safe with default)
# ─── .popitem() — remove & return LAST inserted pair ────
last = scores.popitem() # ('Dan', 73)
print(last)
# ─── .setdefault() — get, or add if missing ─────────────
# Perfect for building dicts of lists
groups = {}
for word in ["apple", "ant", "bee", "ball", "cat"]:
groups.setdefault(word[0], []).append(word)
print(groups)
# {'a': ['apple', 'ant'], 'b': ['bee', 'ball'], 'c': ['cat']}
# ─── .clear() — remove ALL entries ─────────────────────
scores.clear()
print(scores) # {}
# ─── .copy() — shallow copy ─────────────────────────────
original = {"a": 1, "b": 2}
duplicate = original.copy() # independent dict
duplicate["c"] = 3
print(original) # {'a': 1, 'b': 2} — untouched
print(duplicate) # {'a': 1, 'b': 2, 'c': 3}
Complete Methods Reference
| Category | Method | What It Does | Returns |
|---|---|---|---|
| Views | .keys() | Live view of all keys | dict_keys |
| .values() | Live view of all values | dict_values | |
| .items() | Live view of (key, value) tuples | dict_items | |
| Read | .get(k, default) | Safe lookup — returns default if missing | value or default |
| k in d | Membership test (fast, hash-based) | bool | |
| len(d) | Number of entries | int | |
| Mutate | d[k] = v | Add or update entry | — |
| .update(other) | Merge another dict or pairs | None | |
| .setdefault(k, def) | Get, or add if missing | existing or new value | |
| .pop(k, default) | Remove & return value | value | |
| .popitem() | Remove & return last (key, value) | tuple | |
| Delete | del d[k] | Remove entry by key | — |
| .clear() | Remove all entries | None | |
| Build | .copy() | Shallow copy | new dict |
| dict.fromkeys(keys, v) | Build dict with same value for many keys | new dict |
The for Loop — Iterating Over Dictionaries
Looping over a dict is where you spend most of your dictionary time. Python offers three neat patterns, one for each view method, plus useful patterns for filtering and building new dicts.
Pattern 1 — Loop Over Keys (the default)
ages = {"Alice": 30, "Bob": 25, "Carol": 35}
# Default iteration gives you the KEYS
for name in ages:
print(name)
# OUTPUT: Alice, Bob, Carol
# Equivalent (explicit)
for name in ages.keys():
print(name)
Pattern 2 — Loop Over Values
for age in ages.values():
print(age)
# OUTPUT: 30, 25, 35
# Aggregate quickly
print(sum(ages.values())) # 90
print(max(ages.values())) # 35
print(sum(ages.values()) / len(ages)) # 30.0 average
Pattern 3 — Loop Over Both With .items()
# The MOST COMMON pattern — unpack each (key, value) tuple
for name, age in ages.items():
print(f"{name} is {age}")
# OUTPUT:
# Alice is 30
# Bob is 25
# Carol is 35
# Filter while looping
adults = [name for name, age in ages.items() if age >= 30]
print(adults) # ['Alice', 'Carol']
# Sort by value
for name, age in sorted(ages.items(), key=lambda x: x[1]):
print(f"{name}: {age}")
# OUTPUT:
# Bob: 25
# Alice: 30
# Carol: 35
Pattern 4 — Dict Comprehensions
# ─── BUILD A DICT WITH A COMPREHENSION ──────────────────
# {key_expr: value_expr for item in iterable}
squares = {n: n**2 for n in range(1, 6)}
print(squares) # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
# ─── TRANSFORM AN EXISTING DICT ─────────────────────────
prices = {"apple": 1.0, "bread": 3.0, "milk": 2.5}
with_tax = {name: price * 1.15 for name, price in prices.items()}
print(with_tax) # {'apple': 1.15, 'bread': 3.45, 'milk': 2.875}
# ─── SWAP KEYS AND VALUES ───────────────────────────────
inverted = {v: k for k, v in prices.items()}
print(inverted) # {1.0: 'apple', 3.0: 'bread', 2.5: 'milk'}
# ─── FILTER WHILE BUILDING ──────────────────────────────
cheap = {name: price for name, price in prices.items() if price < 3}
print(cheap) # {'apple': 1.0, 'milk': 2.5}
Adding or deleting keys while looping over a dict raises RuntimeError: dictionary changed size during iteration. If you need to remove entries, either build a new dict with a comprehension, or iterate over a copy of the keys: for k in list(d.keys()):.
Nested Dictionaries — Structured Data
A dictionary's value can itself be a dictionary. This is how you model JSON, database rows, configuration files, and any hierarchical data in Python. Nested dicts are so common that JSON literally uses this exact structure.
# ─── A REGISTRY OF USER RECORDS ─────────────────────────
users = {
"alice": {
"name": "Alice Johnson",
"age": 30,
"skills": ["Python", "SQL"],
"active": True
},
"bob": {
"name": "Bob Smith",
"age": 25,
"skills": ["Go", "Rust"],
"active": False
}
}
# Nested access with chained brackets
print(users["alice"]["name"]) # Alice Johnson
print(users["alice"]["skills"][0]) # Python
# Safer nested access with .get() chained
email = users.get("carol", {}).get("email", "n/a")
print(email) # n/a
# ─── LOOP OVER NESTED DATA ──────────────────────────────
for username, info in users.items():
status = "active" if info["active"] else "inactive"
print(f"{info['name']:<20} — {status} — {len(info['skills'])} skills")
# ─── UPDATE A NESTED VALUE ──────────────────────────────
users["alice"]["skills"].append("Kubernetes")
print(users["alice"]["skills"]) # ['Python', 'SQL', 'Kubernetes']
Complete Practice Program — Word Frequency Counter
# word_counter.py — a real dictionary workout
def count_words(text):
"""Return a dict mapping word → count."""
counts = {}
for word in text.lower().split():
word = word.strip(".,!?;:\"'")
if word:
counts[word] = counts.get(word, 0) + 1
return counts
def main():
text = """
The quick brown fox jumps over the lazy dog.
The dog barks. The fox runs. The lazy dog sleeps again.
Python makes counting words a breeze — the dict does the work.
"""
line = "─" * 50
counts = count_words(text)
# ─── BASIC STATS ────────────────────────────────────
print(f"{line}\n WORD FREQUENCY REPORT\n{line}")
print(f" Unique words: {len(counts)}")
print(f" Total words: {sum(counts.values())}")
# ─── TOP 5 MOST COMMON ─────────────────────────────
top5 = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[:5]
print(f"\n {'WORD':<15}{'COUNT':>6}{'BAR':>20}")
for word, count in top5:
bar = "█" * count
print(f" {word:<15}{count:>6} {bar}")
# ─── DICT COMPREHENSION — filter words appearing 2+ times ─
frequent = {w: c for w, c in counts.items() if c >= 2}
print(f"\n Words appearing 2+ times: {frequent}")
# ─── setdefault: GROUP WORDS BY LENGTH ─────────────
by_length = {}
for word in counts:
by_length.setdefault(len(word), []).append(word)
print(f"\n Grouped by length:")
for length, words in sorted(by_length.items()):
print(f" {length} chars: {words}")
# ─── SAFE LOOKUP with .get() ───────────────────────
for query in ["the", "missing"]:
found = counts.get(query, 0)
print(f" '{query}' appears {found} time(s)")
if __name__ == "__main__":
main()