Working with Nested Lists and Dictionaries

Real-world data is rarely as flat as the simple examples earlier in this series. python nested dictionaries and python nested lists — structures containing other structures — are the actual shape of almost everything you'll work with once you start pulling in API responses, JSON files, or configuration data. This article covers building, accessing, modifying, and safely navigating that kind of layered data.

Introduction: Data Structures Inside Data Structures

Nesting simply means using a list or dictionary as an element or value inside another list or dictionary.

nested = {"user": {"name": "Alex", "roles": ["admin", "editor"]}}

That single line already contains three levels: an outer dictionary, an inner dictionary as one of its values, and a list nested inside that inner dictionary.

Why this matters

Real-world data — API responses, parsed JSON, configuration files — is almost never flat. A user profile has an address, which has its own fields. An order has a list of line items, each of which is its own record with several fields. Learning to navigate this kind of layered structure isn't an advanced, rarely-needed skill — it's close to unavoidable the moment you start working with data from outside your own program.

Four combinations, covered in this article
  • List of lists — a grid or matrix-like structure.

  • List of dictionaries — a common shape for records, like rows from a database or API.

  • Dictionary of dictionaries — records keyed by some identifier, each holding several fields.

  • Dictionary of lists — a single key mapping to multiple related values.

Creating and Accessing Nested Structures

Building a dictionary of dictionaries
students = {
    "Alex": {"id": 101, "email": "[email protected]", "grade": "A"},
    "Sam": {"id": 102, "email": "[email protected]", "grade": "B"},
}

Here, each student's name is the outer key, and the value is itself a dictionary holding that student's individual fields.

Building a list of dictionaries
students = [
    {"name": "Alex", "id": 101, "grade": "A"},
    {"name": "Sam", "id": 102, "grade": "B"},
]

This shape — a list of dictionaries — is extremely common in practice, since it's exactly what you get back from most API endpoints returning a collection of records, and it's the direct equivalent of rows in a database table.

A dictionary containing lists
student_hobbies = {
    "Alex": ["reading", "hiking", "chess"],
    "Sam": ["painting", "cycling"],
}

Here, each key maps to a list of multiple related values, rather than a single dictionary of fields.

Chaining lookups to reach a deeply nested value

Accessing a deeply nested value just means chaining bracket lookups, one level at a time:

students = {
    "Alex": {"id": 101, "email": "[email protected]", "grade": "A"},
}

print(students["Alex"]["email"])   # [email protected]

With a list of dictionaries, you combine index-based access with key-based access:

students = [
    {"name": "Alex", "id": 101, "grade": "A"},
    {"name": "Sam", "id": 102, "grade": "B"},
]

print(students[0]["name"])   # Alex — first list item, then its "name" key
Each level must be indexed in the right order

This is worth stating plainly: you need to know whether a given level is a list (indexed by position, [0]) or a dictionary (indexed by key, ["email"]) at each step, and access it accordingly. Mixing these up — trying to use a string key on a list, or a numeric index on a dictionary — raises an error immediately:

students = [{"name": "Alex"}]

print(students["name"])
# TypeError: list indices must be integers or slices, not str

Modifying Nested Data

Adding a new key to an inner dictionary
students = {"Alex": {"id": 101, "grade": "A"}}

students["Alex"]["email"] = "[email protected]"
print(students)
# {'Alex': {'id': 101, 'grade': 'A', 'email': '[email protected]'}}
Adding a new nested entry to the outer structure
students["Sam"] = {"id": 102, "grade": "B"}
print(students)
# {'Alex': {...}, 'Sam': {'id': 102, 'grade': 'B'}}
Updating a value several levels deep
students = {"Alex": {"id": 101, "email": "[email protected]", "grade": "A"}}

students["Alex"]["email"] = "[email protected]"
print(students["Alex"]["email"])   # [email protected]

Each bracket access along the chain navigates one level deeper, and the final bracket is what actually gets assigned to.

Appending to a list nested inside a dictionary value
student_hobbies = {"Alex": ["reading", "hiking"]}

student_hobbies["Alex"].append("chess")
print(student_hobbies)   # {'Alex': ['reading', 'hiking', 'chess']}

This works exactly like appending to any other list — you just need to navigate to it first via the dictionary key.

Safely Accessing Uncertain or Deeply Nested Data

The problem: real data isn't uniformly shaped

Data coming from outside your program — an API, a file, user input — doesn't always guarantee every key will be present, or that every value will be the type you expect. Chained bracket access has no tolerance for this:

data = {"user": {"name": "Alex"}}

print(data["user"]["email"])
# KeyError: 'email' — this key simply doesn't exist for this record

If even one key is missing anywhere along the chain, your program crashes immediately with a KeyError (or a TypeError, if a level you expected to be a dict turns out to be something else, like None).

Safer access with chained .get() and defaults

The .get() method, covered in the earlier dictionaries article, extends naturally to nested access — you just chain it, providing a fallback at each level:

data = {"user": {"name": "Alex"}}

email = data.get("user", {}).get("email", "No email provided")
print(email)   # No email provided

Here, data.get("user", {}) returns the inner dictionary if "user" exists, or an empty dictionary if it doesn't — either way, the next .get("email", ...) call always has a dictionary to operate on, so it never raises an error, regardless of how incomplete the source data turns out to be.

When to let it fail loudly instead

Safe access isn't always the right call. If a missing key genuinely represents a bug — data that should always be present, and its absence signals something is actually broken upstream — deliberately letting a KeyError happen (rather than silently substituting a default) is often the more honest choice. Swallowing every possible missing key with a default can hide real problems instead of surfacing them where they're easiest to diagnose. Use .get() with defaults for data that's legitimately optional; use direct bracket access (and let it raise) for data you genuinely expect to always be there.

A small reusable helper for dynamic key paths

For situations where you need to look up a nested value using a path determined at runtime — not hardcoded key names — a small helper function is worth having on hand:

def get_nested(data, keys, default=None):
    """Safely access a nested value using a list of keys."""
    current = data
    for key in keys:
        if isinstance(current, dict) and key in current:
            current = current[key]
        else:
            return default
    return current

data = {"user": {"address": {"city": "Nagpur"}}}

print(get_nested(data, ["user", "address", "city"]))       # Nagpur
print(get_nested(data, ["user", "address", "zip"]))        # None
print(get_nested(data, ["user", "phone", "number"], "N/A")) # N/A

This is particularly useful when the key path itself is configurable or comes from somewhere dynamic — for example, extracting different fields from a JSON response based on a mapping defined elsewhere in your program.

Looping, Comprehensions, and Practical Patterns

Iterating with nested for loops

The natural pattern for processing nested data is an outer loop over the outer structure, with an inner loop handling whatever's nested inside each item:

students = {
    "Alex": {"id": 101, "grade": "A"},
    "Sam": {"id": 102, "grade": "B"},
}

for name, info in students.items():
    print(f"{name}:")
    for field, value in info.items():
        print(f"  {field}: {value}")
Flattening nested data into a simpler structure

Sometimes you don't need the full nested shape — you just need to extract a specific, simpler view out of it. Here's an example pulling city-population pairs out of a three-level state → city → population structure:

populati {
    "California": {"Los Angeles": 3900000, "San Diego": 1400000},
    "Texas": {"Houston": 2300000, "Austin": 960000},
}

city_populati {}
for state, cities in population_data.items():
    for city, population in cities.items():
        city_populations[city] = population

print(city_populations)
# {'Los Angeles': 3900000, 'San Diego': 1400000, 'Houston': 2300000, 'Austin': 960000}

This same flattening pattern can be written more compactly as a nested dict comprehension, echoing the earlier dict/set comprehension article:

city_populati {
    city: population
    for cities in population_data.values()
    for city, population in cities.items()
}
Real-world framing: this is what parsed JSON looks like

If you've ever looked at a JSON response from a web API, this exact shape — dictionaries containing lists, lists containing dictionaries, several layers deep — is almost always what you're looking at, once Python parses it. The json module (covered in a later article in this series) converts JSON directly into this same combination of nested dicts and lists, so every pattern covered in this article — chained access, safe .get() navigation, nested loops — transfers directly to working with real API data, with no additional concepts required.

A caution about over-nesting

While nesting is often unavoidable when it reflects the genuine shape of your data, it's worth watching for the point where it becomes a readability problem rather than a natural representation. If you find yourself writing chains like data["a"]["b"]["c"]["d"]["e"], or nesting comprehensions three or four levels deep, that's usually a sign to step back and reconsider the structure. Beyond roughly two or three levels, it's often worth introducing a dataclass (or a full class) to give each level a named, well-defined shape, rather than continuing to nest plain dictionaries and lists indefinitely. Dictionaries and lists are flexible and convenient for genuinely dynamic or loosely structured data — but once a structure's shape is stable and well-known ahead of time, a proper class often makes the code considerably easier to read, and gives you the benefit of catching typos in field names as an actual error, rather than a silently returned None.