Dictionaries — Key-Value Storage and Lookup

Python dictionaries are the backbone of web development with Python — every JSON request body is parsed into a dict, every SQLAlchemy model instance can be exported as a dict, and FastAPI’s response serialisation works by converting your Pydantic models to dicts before encoding them as JSON. Dictionaries store data as key-value pairs with O(1) average-time lookup by key, and since Python 3.7, they maintain insertion order. Understanding the full dict API — safe access, merging, iteration, and the many transformation patterns — is essential for working effectively with FastAPI’s request and response data.

Creating and Accessing Dictionaries

# Create with curly braces
user = {"name": "Alice", "age": 30, "role": "admin"}

# Create with dict() constructor
post = dict(title="My Post", published=True, views=0)

# Create from a list of tuples
config = dict([("host", "localhost"), ("port", 5432)])

# Empty dict
empty = {}

# Accessing values
user["name"]    # "Alice" — raises KeyError if key missing

# Safe access with get() — returns None (or default) if key missing
user.get("name")           # "Alice"
user.get("email")          # None — no KeyError
user.get("email", "N/A")   # "N/A" — custom default

# Check if key exists
"name" in user             # True
"email" in user            # False
"admin" in user.values()   # True — check values

# Get all keys, values, items
user.keys()    # dict_keys(["name", "age", "role"])
user.values()  # dict_values(["Alice", 30, "admin"])
user.items()   # dict_items([("name", "Alice"), ("age", 30), ("role", "admin")])
Note: Dictionary keys must be hashable — strings, numbers, tuples (of hashables), and frozensets are all valid keys. Lists, dicts, and sets cannot be keys because they are mutable. In FastAPI applications, dict keys are almost always strings (corresponding to JSON object field names). SQLAlchemy’s ORM layer automatically converts database column values into Python dicts with string keys matching column names.
Tip: Always use .get() instead of direct bracket access when the key might not exist. Direct access (d["key"]) raises a KeyError if the key is missing — a common crash in web applications when optional request fields are not included. The pattern d.get("field", default_value) is safe and explicit. In FastAPI, Pydantic models handle this automatically, but when working with raw dicts from database queries, always use .get() for optional fields.
Warning: Dictionaries are mutable and passed by reference — assigning a dict to another variable gives both variables a reference to the same dict object. Modifying one modifies both. To create an independent copy, use dict.copy() for a shallow copy or copy.deepcopy(d) for a deep copy of nested structures. The {**existing} spread syntax also creates a shallow copy while allowing field additions or overrides.

Modifying Dictionaries

user = {"name": "Alice", "age": 30}

# Add or update a single key
user["email"] = "alice@example.com"    # add new key
user["age"]   = 31                     # update existing key

# update() — add/update multiple keys at once
user.update({"role": "admin", "age": 32})
user.update(role="editor")             # keyword argument form

# setdefault() — add key only if it doesn't exist
user.setdefault("is_active", True)     # adds is_active: True
user.setdefault("name", "Unknown")     # does NOT overwrite existing "name"
print(user["name"])                    # still "Alice"

# Delete keys
del user["age"]                        # raises KeyError if missing
popped = user.pop("role")              # removes and returns value
user.pop("missing", None)              # safe delete — returns None if missing

# Clear all keys
user.clear()   # {} — empty dict

# Merge dicts — create new dict with combined keys
base    = {"a": 1, "b": 2}
extra   = {"b": 20, "c": 3}

# Python 3.9+ merge operator
merged  = base | extra     # {"a": 1, "b": 20, "c": 3} — extra wins on conflict

# All Python 3 — spread into new dict
merged  = {**base, **extra}  # same result

# Conditional update
update_data = {"name": "Bob", "bio": None}
# Only update keys where value is not None
user = {k: v for k, v in {**user, **update_data}.items() if v is not None}

Iterating Over Dictionaries

post = {"id": 1, "title": "My Post", "views": 150, "published": True}

# Iterate over keys (default)
for key in post:
    print(key)   # id, title, views, published

# Iterate over values
for value in post.values():
    print(value)   # 1, "My Post", 150, True

# Iterate over key-value pairs (most common)
for key, value in post.items():
    print(f"{key}: {value}")

# Build a new dict from iteration (dict comprehension)
# Exclude sensitive fields
safe_post = {k: v for k, v in post.items() if k != "author_password_hash"}

# Transform all values
upper_keys = {k.upper(): v for k, v in post.items()}
# {"ID": 1, "TITLE": "My Post", "VIEWS": 150, "PUBLISHED": True}

# Filter by value type
text_only = {k: v for k, v in post.items() if isinstance(v, str)}
# {"title": "My Post"}

Nested Dictionaries — FastAPI Response Patterns

# Nested dict — common in API responses
response = {
    "success":  True,
    "data": {
        "id":    1,
        "title": "My Post",
        "author": {
            "id":   42,
            "name": "Alice",
        },
        "tags": ["python", "fastapi"],
    },
    "meta": {
        "total": 100,
        "page":  1,
        "limit": 10,
    }
}

# Access nested values
response["data"]["author"]["name"]              # "Alice"
response.get("data", {}).get("author", {}).get("name")  # safer chain

# Update nested value
response["data"]["title"] = "Updated Title"

# Add field to nested dict
response["data"]["slug"] = "my-post"

# Flatten nested for database storage
def flatten_user(user_dict: dict) -> dict:
    return {
        "user_id":   user_dict["id"],
        "user_name": user_dict["name"],
        "user_email": user_dict.get("email", ""),
    }

Common Mistakes

Mistake 1 — KeyError from direct access on optional key

❌ Wrong — crashes if key is absent:

bio = user["bio"]   # KeyError if "bio" not in user

✅ Correct — use get() with a default:

bio = user.get("bio", "")   # "" if bio is missing ✓

Mistake 2 — Mutating a dict while iterating over it

❌ Wrong — RuntimeError: dictionary changed size during iteration:

for key in d:
    if key.startswith("_"):
        del d[key]   # RuntimeError!

✅ Correct — iterate over a copy of the keys:

for key in list(d.keys()):
    if key.startswith("_"):
        del d[key]   # ✓ safe

Mistake 3 — Using mutable value as dict default in get()

❌ Wrong — same list object returned as default every time:

items = d.get("items", [])
items.append("x")   # modifies the default [] object — or does it?
# Actually safe with get() since a new [] is created each call.
# But don't rely on this — use defaultdict instead.

✅ Correct for accumulating — use defaultdict (covered in Lesson 5).

Quick Reference

Operation Code
Create {"key": "value"} or dict(key="value")
Safe access d.get("key", default)
Add/update key d["key"] = value
Update many d.update({"k": v})
Add if missing d.setdefault("key", default)
Delete key del d["key"] or d.pop("key", None)
Merge (new dict) {**d1, **d2} or d1 | d2 (3.9+)
Iterate pairs for k, v in d.items():
Check key "key" in d
All keys/values d.keys(), d.values()

🧠 Test Yourself

You receive a user update dict from a PATCH request: {"name": "Bob", "bio": None}. You want to apply only the non-None fields to the existing user dict. Which one-liner achieves this cleanly?