Python

How to Get Values From a Nested Dictionary in Python

Python · Published 2026-10-24

A row of painted Russian nesting dolls from largest to smallest
Nesting dolls lined up from the largest to the smallest.

To read a value from a nested dictionary, chain the keys: data["customer"]["address"]["city"]. If any level might be missing, use .get() with a default at each level or a small deep_get(data, "customer.address.city") helper, and switch to recursion, pandas.json_normalize or a query library when the structure is deep or irregular.

All examples below use this order record, the kind of structure you get from json.loads() on an API response:

order = {
    "id": 1042,
    "customer": {
        "name": "Ada Lovelace",
        "address": {"city": "London", "postcode": "W1"},
        "tags": ["vip", "newsletter"],
    },
    "items": [
        {"sku": "A-1", "qty": 2, "price": 9.5},
        {"sku": "B-7", "qty": 1, "price": 24.0},
    ],
    "shipping": None,
}

Direct indexing

Each pair of brackets descends one level. Dictionaries take keys, lists take integer positions, and you can mix them freely:

print(order["customer"]["address"]["city"])
print(order["items"][1]["sku"])
print(order["customer"]["tags"][-1])
London
B-7
newsletter

This is the right choice when the key must exist: if it does not, you get an immediate KeyError (or IndexError for lists) that tells you exactly which key was missing.

order["customer"]["phone"]
KeyError: 'phone'

Chained .get() with defaults

dict.get(key, default) returns the default instead of raising. For nested access, the default at every intermediate level must be an empty dict so that the next .get() still works:

print(order.get("customer", {}).get("address", {}).get("country", "UK"))
UK

The catch: the default is only used when the key is absent. When the key exists with the value None (JSON null), you get None back and the chain breaks:

print(order.get("shipping", {}).get("method", "standard"))
AttributeError: 'NoneType' object has no attribute 'get'

Use or {} to cover both cases: (order.get("shipping") or {}).get("method", "standard"). Be aware that or also replaces other falsy values such as an empty dict, which is usually what you want at intermediate levels.

A safe deep_get helper

Long .get() chains are hard to read and do not handle lists. A small helper that walks a path of keys and indexes is clearer and reusable:

_MISSING = object()

def deep_get(data, path, default=None):
    """Follow a sequence of keys/indexes; return default if any step is missing."""
    if isinstance(path, str):
        path = path.split(".")
    current = data
    for key in path:
        if isinstance(current, dict):
            current = current.get(key, _MISSING)
        elif isinstance(current, (list, tuple)):
            try:
                current = current[int(key)]
            except (ValueError, IndexError):
                current = _MISSING
        else:
            current = _MISSING
        if current is _MISSING:
            return default
    return current

print(deep_get(order, "customer.address.city"))
print(deep_get(order, "items.1.price"))
print(deep_get(order, ["items", 0, "sku"]))
print(deep_get(order, "items.5.sku", default="n/a"))
print(deep_get(order, "shipping.method", default="standard"))
print(deep_get(order, "customer.name.first"))
London
24.0
A-1
n/a
standard
None

Design notes:

If you only need dictionaries and are happy with exceptions, the standard library can do the walk in one line: functools.reduce(operator.getitem, ["customer", "address", "postcode"], order) returns 'W1' and raises KeyError on a missing key.

Lists inside dictionaries

When a level holds a list of dicts, you usually want a value from every element, not one index. A comprehension expresses that directly:

skus = [item["sku"] for item in order["items"]]
total = sum(item["qty"] * item["price"] for item in order["items"])
print(skus, total)
['A-1', 'B-7'] 43.0

Use item.get("sku") inside the comprehension if some elements may lack the key, and add if item.get("sku") to skip them.

Find every value for a key, at any depth

For irregular data (configuration trees, GraphQL responses, scraped JSON) where the same key appears at unknown depths, a recursive generator collects every occurrence:

def find_values(obj, key):
    """Yield every value stored under `key`, at any depth."""
    if isinstance(obj, dict):
        for k, v in obj.items():
            if k == key:
                yield v
            yield from find_values(v, key)
    elif isinstance(obj, list):
        for item in obj:
            yield from find_values(item, key)

print(list(find_values(order, "sku")))
print(list(find_values(order, "city")))
['A-1', 'B-7']
['London']

Python's default recursion limit (about 1000 frames) is far deeper than any realistic JSON document, so recursion is fine here. Because it is a generator, next(find_values(data, "id"), None) returns just the first match without walking the whole structure.

Flatten with pandas.json_normalize

If the goal is analysis rather than a single lookup, flatten the structure into columns with pandas.json_normalize. Nested dicts become dotted (or custom-separated) column names:

import pandas as pd

flat = pd.json_normalize(order, sep="_")
print(flat.columns.tolist())
print(flat.loc[0, "customer_address_city"])
['id', 'items', 'shipping', 'customer_name', 'customer_address_city', 'customer_address_postcode', 'customer_tags']
London

Lists are left as-is. To turn a nested list of records into rows, name it with record_path and pull parent fields along with meta:

items = pd.json_normalize(order, record_path="items", meta=["id", ["customer", "name"]])
print(items)
   sku  qty  price    id customer.name
0  A-1    2    9.5  1042  Ada Lovelace
1  B-7    1   24.0  1042  Ada Lovelace

Passing a list of records (for example a whole API page) produces one row per record.

Query libraries: glom and JMESPath

When paths get complex, a declarative query is easier to maintain than hand-written loops. Two well-established third-party options (pip install glom jmespath):

from glom import glom, Coalesce
import jmespath

print(glom(order, "customer.address.city"))
print(glom(order, ("items", ["sku"])))
print(glom(order, Coalesce("customer.phone", default="none on file")))

print(jmespath.search("customer.address.city", order))
print(jmespath.search("items[?qty > `1`].sku", order))
print(jmespath.search("customer.phone", order))
London
['A-1', 'B-7']
none on file
London
['A-1']
None

glom (tested with 25.12) raises a descriptive PathAccessError: could not access 'phone', part 1 of Path('customer', 'phone') on missing paths unless you give a default, and it can also restructure data. JMESPath (1.1) returns None for missing paths and supports filters and projections; the same syntax is used by the AWS CLI's --query option, which makes it a good fit for teams that already know it.

Give the structure a shape: TypedDict and dataclasses

If your code reads the same nested structure in many places, describe it once. TypedDict keeps plain dicts at runtime but lets a type checker validate every key you access:

from typing import NotRequired, TypedDict


class Address(TypedDict):
    city: str
    postcode: str


class Customer(TypedDict):
    name: str
    address: Address
    phone: NotRequired[str]


def bad(customer: Customer) -> str:
    return customer["adress"]["city"]
t9.py:24: error: TypedDict "Customer" has no key "adress"  [typeddict-item]
t9.py:24: note: Did you mean "address"?

NotRequired is available in typing from Python 3.11 (use typing_extensions on older versions). Note that mypy does not, by default, complain about customer["phone"] even though the key is optional; use customer.get("phone") for those keys.

For data you want to work with as objects, convert the dict once at the boundary into dataclasses and use attribute access everywhere else:

from dataclasses import dataclass


@dataclass
class Item:
    sku: str
    qty: int
    price: float


@dataclass
class Order:
    id: int
    city: str
    items: list[Item]

    @classmethod
    def from_dict(cls, d: dict) -> "Order":
        return cls(
            id=d["id"],
            city=d["customer"]["address"]["city"],
            items=[Item(**i) for i in d["items"]],
        )


o = Order.from_dict(order)
print(o.city, o.items[0].sku, sum(i.qty * i.price for i in o.items))
London A-1 43.0

All the nested lookups now live in one function, so a change in the incoming format means one fix. For validation of untrusted input (types, required fields, helpful error messages), libraries such as pydantic build on the same idea.

Common pitfalls

Summary

Chain brackets when keys are guaranteed, chain .get() (with or {} for None values) for a few optional levels, and use a deep_get helper for arbitrary paths through dicts and lists. Use comprehensions for lists of records, a recursive generator to find a key at any depth, pd.json_normalize to flatten for analysis, glom or JMESPath for complex queries, and TypedDict or dataclasses when the same structure is used throughout a codebase.