PythonNested Data Structures

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.

Python
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.

Python
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.

Python
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.

Python
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.

Python
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/A
Note
The `isinstance(current, dict)` check matters just as much as the key check. Without it, calling `.get()` on something that turned out to be a list or a string (a `TypeError`-in-waiting) would crash instead of falling back to the default.
Iterating with Nested Loops and Comprehensions

Nested structures usually call for nested loops — or their more compact cousin, the nested comprehension.

Python
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']}
Tip
Nested comprehensions are read left-to-right in the same order you would write the equivalent nested for-loops: outer loop first, inner loop second. If a comprehension needs more than two levels of nesting or an `if` clause that is hard to read at a glance, prefer writing it out as a regular loop — clarity beats cleverness.
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.

Python
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 object
Warning
Reassigning a top-level key on a shallow copy (like `shallow["name"] = "Grace"` above) is safe — it only changes the copy. But mutating a nested value *in place* (`.append()`, `.update()`, item assignment) reaches through the shared reference and changes the original too. The same trap applies to a list of dicts: `new_list = old_list.copy()` gives you a new outer list, but `new_list[0]` and `old_list[0]` are still the same dict object.

The 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.

Python
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 now
  • copy.copy(x) (or x.copy(), or x[:]) 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.

Note
A common half-measure is a shallow copy one level deeper — for example rebuilding a list of dicts with `[d.copy() for d in original_list]`. That protects each dict’s top-level keys, but any list or dict *inside* those dicts is still shared. When in doubt about how deep your structure goes, `deepcopy` is the safe default.