Nested Data Structures
Real-world data is rarely flat. A user record has a list of roles, an order has a list of line items, and a configuration file is a tree of settings nested inside other settings. Python lets you build these shapes freely by putting lists inside dicts, dicts inside lists, and dicts inside dicts — nesting containers as deeply as the problem requires. This page covers the common shapes you will run into, how to read from them safely, and a subtle copying bug that catches almost every Python developer at least once.
Lists of Dicts
The most common nested shape is a list of dicts — think of it as a table where each dict is a row. This is exactly what you get back from most JSON APIs and database query results.
users = [
{"name": "Ada", "age": 36, "roles": ["admin", "editor"]},
{"name": "Grace", "age": 85, "roles": ["admin"]},
{"name": "Linus", "age": 55, "roles": ["editor", "viewer"]},
]
# Loop over the list, each item is a dict
for user in users:
print(user["name"], "->", user["roles"])
# Ada -> ['admin', 'editor']
# Grace -> ['admin']
# Linus -> ['editor', 'viewer']
# Find everyone with the "admin" role
admins = [u["name"] for u in users if "admin" in u["roles"]]
print(admins) # ['Ada', 'Grace']Dicts of Lists
Flip the shape around and you get a dict of lists — useful when you want to group values under a small number of keys, like tags mapped to the items that have them.
roles_to_users = {
"admin": ["Ada", "Grace"],
"editor": ["Ada", "Linus"],
"viewer": ["Linus"],
}
for role, names in roles_to_users.items():
print(role, "has", len(names), "member(s):", names)
# admin has 2 member(s): ['Ada', 'Grace']
# editor has 2 member(s): ['Ada', 'Linus']
# viewer has 1 member(s): ['Linus']
# Add a new member to an existing role
roles_to_users["editor"].append("Grace")
print(roles_to_users["editor"]) # ['Ada', 'Linus', 'Grace']Dicts of Dicts
When each item needs a mix of scalar fields and its own sub-fields, a dict keyed by ID with dict values is a natural fit — this is essentially an in-memory table indexed by primary key.
users_by_id = {
1: {"name": "Ada", "address": {"city": "London", "zip": "SW1A"}},
2: {"name": "Grace", "address": {"city": "New York", "zip": "10001"}},
}
print(users_by_id[1]["name"]) # Ada
print(users_by_id[1]["address"]["city"]) # London
# Update a deeply nested value
users_by_id[2]["address"]["city"] = "Arlington"
print(users_by_id[2]["address"]) # {'city': 'Arlington', 'zip': '10001'}Accessing Deeply Nested Values Safely
Chaining lookups like user_data["address"]["city"] works great until one of the keys is missing, at which point Python raises a KeyError. Real data — especially data coming from an external API — is often incomplete, so defensive access matters.
user_data = {"name": "Ada"} # no "address" key this time
# This blows up:
# city = user_data["address"]["city"]
# KeyError: 'address'
# Chained .get() with a default dict at each step avoids the crash
city = user_data.get("address", {}).get("city", "Unknown")
print(city) # Unknown
# Same idea, several levels deep
config = {"db": {"host": "localhost"}}
port = config.get("db", {}).get("port", 5432)
print(port) # 5432 (falls back because "port" isn't set)For deeper trees, writing out .get(...).get(...).get(...) gets unwieldy. A small helper function keeps the intent clear and stays safe against missing keys or wrong types.
def safe_get(data, *keys, default=None):
"""Walk a chain of keys through nested dicts, returning default on any miss."""
current = data
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
else:
return default
return current
profile = {"user": {"address": {"city": "London"}}}
print(safe_get(profile, "user", "address", "city")) # London
print(safe_get(profile, "user", "address", "country")) # None
print(safe_get(profile, "user", "phone", "number", default="N/A")) # N/AIterating with Nested Loops and Comprehensions
Nested structures usually call for nested loops — or their more compact cousin, the nested comprehension.
teams = {
"backend": ["Ada", "Grace"],
"frontend": ["Linus", "Margaret"],
}
# Nested for-loops: readable, easy to add extra logic to
for team, members in teams.items():
for member in members:
print(f"{member} works on {team}")
# Ada works on backend
# Grace works on backend
# Linus works on frontend
# Margaret works on frontend
# The same result as a nested comprehension
pairs = [(member, team) for team, members in teams.items() for member in members]
print(pairs)
# [('Ada', 'backend'), ('Grace', 'backend'), ('Linus', 'frontend'), ('Margaret', 'frontend')]
# A nested comprehension can also build a nested structure back up,
# e.g. uppercase every name while keeping the same shape
shouted = {team: [name.upper() for name in members] for team, members in teams.items()}
print(shouted)
# {'backend': ['ADA', 'GRACE'], 'frontend': ['LINUS', 'MARGARET']}The Shallow Copy Trap
This is the bug that catches nearly everyone at least once. Calling .copy() on a list or dict — or slicing with [:] — creates a shallow copy: a brand-new outer container, but the elements inside it are the same objects as in the original. If those elements are themselves mutable (lists, dicts), mutating them through the "copy" mutates the original too.
original = {"name": "Ada", "roles": ["admin", "editor"]}
shallow = original.copy() # new dict, but "roles" still points to the SAME list
shallow["name"] = "Grace" # only touches the top level -> original is unaffected
shallow["roles"].append("owner") # mutates the shared inner list!
print(original)
# {'name': 'Ada', 'roles': ['admin', 'editor', 'owner']} <- "owner" leaked in!
print(shallow)
# {'name': 'Grace', 'roles': ['admin', 'editor', 'owner']}
print(original["roles"] is shallow["roles"]) # True - it's literally the same list objectThe fix is copy.deepcopy() from the standard library's copy module. It recursively copies every nested container, so the copy shares no mutable state with the original at any depth.
import copy
original = {"name": "Ada", "roles": ["admin", "editor"]}
deep = copy.deepcopy(original)
deep["roles"].append("owner")
print(original) # {'name': 'Ada', 'roles': ['admin', 'editor']} <- untouched
print(deep) # {'name': 'Ada', 'roles': ['admin', 'editor', 'owner']}
print(original["roles"] is deep["roles"]) # False - genuinely separate lists nowcopy.copy(x)(orx.copy(), orx[:]) makes a shallow copy: new outer container, shared inner objects.copy.deepcopy(x)recursively copies every nested container so nothing is shared.Shallow copies are fine when the values are immutable (numbers, strings, tuples of immutables) since there is nothing to accidentally mutate in place.
Deep copies cost more time and memory because every nested container gets duplicated - reach for them only when you actually need full independence.