Dictionaries in Python

The mapping type at the heart of Python — powering namespaces, attributes, and your everyday data structures

The Mental Model: Names to Objects

A dictionary is a mapping — it associates keys with values. But understanding how it does this reveals a lot about Python itself.

python
person = {"name": "Alice", "age": 30}
Why Dicts Matter

Dictionaries aren't just a data structure — the language itself runs on them. When you create a variable, Python stores it in a dictionary (__dict__). When you access an attribute, Python looks it up in a dictionary. Understanding dicts means understanding Python's internals.

Key Insight #1: Only Hashable Objects Can Be Keys

This trips up many developers coming from other languages:

Fails
my_dict = {[1, 2, 3]: "value"}
# TypeError: unhashable type: 'list'
Works
my_dict = {(1, 2, 3): "value"}
# Tuples are hashable

Why? Dictionaries are hash tables. Python computes a hash of the key to determine where to store the value. Mutable objects like lists can change, which would break the hash table's invariants.

The Hashability Rule

An object is hashable if it has a __hash__() method returning a consistent value, can be compared via __eq__(), and objects that compare equal have the same hash.

Hashable Types
str, int, float, tuple
frozenset, None
Unhashable Types
list, dict, set

Key Insight #2: Insertion Order Preserved (Python 3.7+)

A significant change that developers from older Python versions might not expect:

python
d = {}
d["first"] = 1
d["second"] = 2
d["third"] = 3

for key in d:
    print(key)
# Output: first, second, third (guaranteed)
Language Guarantee

Since Python 3.7, iteration order matches insertion order. This is a language guarantee, not an implementation detail. However, dicts are still optimized for key-based lookup, not positional access.

Key Insight #3: get() vs Square Bracket Access

This is where subtle bugs often hide:

python
config = {"debug": False, "timeout": 30}

# Square brackets raise KeyError if key missing
value = config["missing_key"]  # KeyError: 'missing_key'

# get() returns None (or a default) if key missing
value = config.get("missing_key")       # Returns None
value = config.get("missing_key", 100)  # Returns 100

The Subtlety with Falsy Defaults

Dangerous Pattern
config = {"debug": False}

if config.get("debug"):
    print("Debug mode")
# Never prints! False is falsy
Correct Pattern
config = {"debug": False}

if "debug" in config and config["debug"]:
    print("Debug mode")
python
MISSING = object()

value = config.get("nonexistent", MISSING)
if value is MISSING:
    print("Key was not present")

Key Insight #4: setdefault() and defaultdict

A common pattern is "get the value, or create a default if missing":

python — The verbose way
groups = {}
for item in items:
    key = get_category(item)
    if key not in groups:
        groups[key] = []
    groups[key].append(item)
python — Using setdefault (cleaner)
groups = {}
for item in items:
    key = get_category(item)
    groups.setdefault(key, []).append(item)
python — Using defaultdict (cleanest)
from collections import defaultdict

groups = defaultdict(list)
for item in items:
    groups[get_category(item)].append(item)
The defaultdict Trap

It creates entries on any access, not just assignment:

python
from collections import defaultdict

d = defaultdict(int)
print(d["missing"])  # Prints 0, but also creates the key!
print(dict(d))       # {'missing': 0} — the key now exists

# This creates the key even if you're just checking!
if d["maybe_missing"]:
    pass

Key Insight #5: Dictionary Views Are Live

When you call .keys(), .values(), or .items(), you don't get a copy — you get a view that reflects changes:

python
d = {"a": 1, "b": 2}
keys = d.keys()
print(list(keys))  # ['a', 'b']

d["c"] = 3
print(list(keys))  # ['a', 'b', 'c'] — the view updated!
Can't Modify During Iteration

You can't modify a dictionary while iterating over it — this raises RuntimeError.

Raises RuntimeError
d = {"a": 1, "b": 2, "c": 3}
for key in d:
    if d[key] < 2:
        del d[key]  # RuntimeError!
Iterate Over a Copy
d = {"a": 1, "b": 2, "c": 3}
for key in list(d.keys()):
    if d[key] < 2:
        del d[key]  # Works fine

Key Insight #6: Dictionary Comprehensions

Just like list comprehensions, but for dicts:

python
# Basic syntax
squares = {x: x**2 for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}

# Inverting a dictionary
original = {"a": 1, "b": 2, "c": 3}
inverted = {v: k for k, v in original.items()}
# {1: 'a', 2: 'b', 3: 'c'}

# Filtering
scores = {"alice": 85, "bob": 62, "charlie": 91}
passed = {name: score for name, score in scores.items() if score >= 70}
# {'alice': 85, 'charlie': 91}

Key Insight #7: Merge Operators (Python 3.9+)

Python 3.9 introduced | and |= for dictionaries:

python
defaults = {"color": "blue", "size": "medium"}
overrides = {"size": "large", "weight": "heavy"}

# Merge (creates new dict, right side wins on conflicts)
combined = defaults | overrides
# {'color': 'blue', 'size': 'large', 'weight': 'heavy'}

# Update in place
defaults |= overrides
# defaults is now {'color': 'blue', 'size': 'large', 'weight': 'heavy'}
python
# Unpacking (still works in 3.9+)
combined = {**defaults, **overrides}

# In-place modification
defaults.update(overrides)

Key Insight #8: Identity vs Equality

A subtle source of bugs with dictionary references and copies:

python
a = {"x": [1, 2, 3]}

b = a  # b is the SAME object as a
b["x"].append(4)
print(a)  # {'x': [1, 2, 3, 4]} — a changed too!

Shallow Copy vs Deep Copy

Shallow Copy (Shares Nested)
c = a.copy()  # or dict(a) or {**a}
c["x"].append(5)
print(a)  # a changed! Still linked
Deep Copy (Fully Independent)
import copy
d = copy.deepcopy(a)
d["x"].append(6)
print(a)  # a unchanged!

Key Insight #9: Common Patterns and Idioms

Counting Occurrences

python
from collections import Counter

words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
counts = Counter(words)
# Counter({'apple': 3, 'banana': 2, 'cherry': 1})

counts.most_common(2)  # [('apple', 3), ('banana', 2)]

Safe Nested Access

python
data = {"user": {"profile": {"name": "Alice"}}}

# Dangerous — any missing level raises KeyError
name = data["user"]["profile"]["name"]

# Safe chained get
name = data.get("user", {}).get("profile", {}).get("name")
python
def deep_get(d, *keys, default=None):
    for key in keys:
        if isinstance(d, dict):
            d = d.get(key, default)
        else:
            return default
    return d

name = deep_get(data, "user", "profile", "name", default="Unknown")

Dicts as Switch/Case (Pre-3.10)

python
def handle_add(x, y):
    return x + y

def handle_sub(x, y):
    return x - y

operations = {
    "add": handle_add,
    "sub": handle_sub,
}

result = operations.get(op_name, lambda x, y: None)(a, b)

Performance Characteristics

OperationAverage CaseNotes
d[key]O(1)Hash table lookup
d[key] = valueO(1)Amortized
del d[key]O(1)
key in dO(1)Very fast!
len(d)O(1)
IterationO(n)
d.copy()O(n)Shallow copy
Why O(1) Matters

Checking x in my_list is O(n), but x in my_dict is O(1). For large datasets, this difference is enormous.

Summary: What Makes Python Dicts Unique

ConceptKey Takeaway
Central to PythonNamespaces, object attributes, and class definitions all use dicts internally.
Insertion OrderPreserved since Python 3.7 — a guarantee other languages' hash maps don't provide.
Hashable KeysOnly immutable types (str, int, tuple, etc.) can be keys. Lists and dicts cannot.
get() vs []Use get() for safe access. Watch out for falsy values vs missing keys.
defaultdictCleanest pattern for grouping, but beware — it creates keys on any access.
Live Views.keys(), .values(), .items() are live views, not copies.
Merge Operators| and |= (3.9+) make combining dicts elegant.
Copy Semantics.copy() is shallow. Use copy.deepcopy() for nested structures.