Dictionaries, Part 1: Lookup and How It Works
obj.x works). Master dicts and you've understood the data structure that powers half of all production Python code.After this lesson, you will be able to:
- Create dictionaries using {}, dict(), and dict.fromkeys() and access values safely
- Explain how hash tables achieve O(1) average-case lookup regardless of size
- Understand why keys must be hashable (immutable) and how hash collisions are handled
- Use every major dict method: .get(), .setdefault(), .items(), .update(), and more
- Write dict comprehensions and build nested dict structures safely
- Implement a simplified HashMap class from scratch to demystify Python's dict internals
Before You Start
#Creating and Accessing Dicts
# Three ways to create a dictionary
# Method 1: dict literal
config = {
"font_size": 14,
"autosave": True,
"theme": "dark",
"language": "en",
}
# Method 2: dict() constructor with keyword arguments
point = dict(x=3, y=7, z=0)
# Method 3: dict.fromkeys() -- create from a sequence of keys with a default value
default_scores = dict.fromkeys(["alice", "bob", "charlie"], 0)
print(default_scores) # {'alice': 0, 'bob': 0, 'charlie': 0}
# Method 4: from a list of (key, value) pairs
pairs = [("name", "Alice"), ("age", 30), ("role", "engineer")]
person = dict(pairs)
print(person) # {'name': 'Alice', 'age': 30, 'role': 'engineer'}Which of these is a VALID dict key?
Key Access: [] vs .get()
config = {"font_size": 14, "autosave": True}
# Bracket access -- raises KeyError if key is missing
size = settings["font_size"] # 14
# settings["theme"] # KeyError: 'theme'
# .get() -- returns None (or a default) if key is missing
theme = settings.get("theme") # None (no error)
theme = settings.get("theme", "light") # "light" (custom default)
# Check membership before access
if "language" in settings:
print(settings["language"])
else:
print("No language set")
# A common pattern: access with fallback
font_size = settings.get("font_size", 12) # safe -- uses 12 if not set#list lookup vs dict lookup: why dicts are O(1)
A list has to scan every element until it finds a match — that's O(n). A dict hashes the key and jumps straight to the bucket — that's O(1). For a million items the dict is roughly a million times faster.
What does this print? settings = {"font_size": 14} print(settings.get("line_height", 10))
.get() is the DEFAULT value — returned when the key is missing. So settings.get("line_height", 10) reads as "give me the value for 'line_height', or 10 if it doesn't exist." This is the canonical safe-access pattern for reading config files and API responses where keys might be missing. Compare with config["line_height"] which would raise KeyError, or config.get("line_height") which would return None.Hit aKeyError? That'sd["missing_key"]on a dict that doesn't have the key. Switch tod.get("missing_key", default)to recover gracefully — full catalog in the error decoder.
d["key"] every millisecond.#Why Keys Must Be Hashable
The hash function must produce the same value for a key every time. If a key could change, its hash would change, and Python would never find it again.
# This is WHY lists cannot be dict keys
# Imagine if you could do this:
# d = {}
# my_list = [1, 2, 3]
# d[my_list] = "value" # stored at bucket: hash([1,2,3]) % size
# my_list.append(4) # now hash([1,2,3,4]) % size != original bucket
# d[my_list] # Python looks in the WRONG bucket -- key "lost"!
# Tuples CAN be keys because they are immutable
locations = {}
locations[(40.7128, -74.0060)] = "New York"
locations[(51.5074, -0.1278)] = "London"
locations[(35.6762, 139.6503)] = "Tokyo"
print(locations[(40.7128, -74.0060)]) # New York -- works!
# Custom class with __hash__
class Color:
"""An immutable RGB color that can be used as a dict key."""
def __init__(self, r: int, g: int, b: int) -> None:
self.r = r
self.g = g
self.b = b
def __hash__(self) -> int:
# Combine the three components into one hash
return hash((self.r, self.g, self.b))
def __eq__(self, other: object) -> bool:
if not isinstance(other, Color):
return NotImplemented
return (self.r, self.g, self.b) == (other.r, other.g, other.b)
def __repr__(self) -> str:
return f"Color({self.r}, {self.g}, {self.b})"
palette = {}
red = Color(255, 0, 0)
blue = Color(0, 0, 255)
palette[red] = "brand red"
palette[blue] = "ocean blue"
# Look up the same color value (different object, same data)
print(palette[Color(255, 0, 0)]) # "brand red" -- works because __hash__ and __eq__ match==), they must have the same hash. Python enforces this contract. If you define __eq__, you must also define __hash__ (or Python sets it to None, making instances unhashable).A dict lookup is O(1) on average. What is it in the worst case, and what would cause that?
#Time Complexity of Dict Operations
| Operation | Average Case | Worst Case | Notes |
|---|---|---|---|
d[key] (access) | O(1) | O(n) | Worst case: all keys collide |
d[key] = val (insert) | O(1) | O(n) | O(n) only during rehash |
del d[key] (delete) | O(1) | O(n) | |
key in d (membership) | O(1) | O(n) | Much faster than key in list |
len(d) | O(1) | O(1) | Stored as an attribute |
Iteration (for k in d) | O(n) | O(n) | Must visit every entry |
.keys(), .values(), .items() | O(1) | O(1) | Return view objects, not copies |
The "worst case O(n)" is a theoretical concern with adversarial inputs. In practice, Python's hash randomization (enabled by default since Python 3.3) makes catastrophic collision attacks infeasible.
#Dict Methods Masterclass
inventory = {"apples": 5, "bananas": 12, "oranges": 8}
# .keys(), .values(), .items() -- return live views
print(list(inventory.keys())) # ['apples', 'bananas', 'oranges']
print(list(inventory.values())) # [5, 12, 8]
print(list(inventory.items())) # [('apples', 5), ('bananas', 12), ('oranges', 8)]
# .get(key, default) -- safe access
print(inventory.get("grapes")) # None
print(inventory.get("grapes", 0)) # 0
# .setdefault(key, default) -- get if exists, set and return default if not
# Useful for "get or initialize" patterns
inventory.setdefault("grapes", 0) # inserts "grapes": 0
inventory["grapes"] += 3 # now "grapes": 3
print(inventory.get("grapes")) # 3
# .update() -- merge another dict in-place
extras = {"mangos": 7, "apples": 20} # "apples" will overwrite
inventory.update(extras)
print(inventory["apples"]) # 20 (overwritten)
print(inventory["mangos"]) # 7 (new)
# .pop(key, default) -- remove and return value
removed = inventory.pop("bananas") # 12 (bananas removed)
missing = inventory.pop("durian", -1) # -1 (key not found, no error)
# .popitem() -- remove and return the LAST inserted (key, value) pair
key, val = inventory.popitem()
print(f"Removed last: {key} -> {val}")
# .clear() -- remove all items
temp = {"a": 1, "b": 2}
temp.clear()
print(temp) # {}
# .copy() -- shallow copy
original = {"data": [1, 2, 3], "name": "Alice"}
copy = original.copy()
copy["name"] = "Bob"
print(original["name"]) # "Alice" (unaffected for immutable values)
# Note: copy is shallow -- nested mutable objects ARE shared
copy["data"].append(99)
print(original["data"]) # [1, 2, 3, 99] -- watch out!You run `d = {'a': 1, 'b': 2}` then `d.update({'b': 20, 'c': 3})`. What is `d` now?
What does this print? counts = {} counts["a"] = counts.get("a", 0) + 1 counts["a"] = counts.get("a", 0) + 1 print(counts)
`d[key] = d.get(key, 0) + 1` and `d.setdefault(key, 0)` then `d[key] += 1` both count occurrences. What is the practical difference?
Why can't you use a list as a dict key?
This program looks up player scores. One player ('dan') isn't in the dict, so the program crashes with a KeyError. Fix it so missing players show as 0.
alice 92 bob 78 cara 85 dan 0