How to Get Values From a Nested Dictionary in Python

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:
- The
_MISSINGsentinel distinguishes "key not present" from "key present with valueNone", so a storedNoneat the end of the path is returned as-is. - Pass a list instead of a dotted string when keys themselves contain dots or are not strings (
["items", 0, "sku"]). - Walking into a string or number (
customer.name.first) returns the default instead of raising, because the path does not exist.
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
.get(key, {})does not protect againstNonevalues. Use.get(key) or {}.- Swallowing errors everywhere. A safe getter on a field that must exist hides bugs; use direct indexing for required data and safe access only for genuinely optional data.
- Integer-looking keys. JSON object keys are always strings, so
data["2024"]works butdata[2024]raisesKeyError. - Mutating while reading.
setdefault()orcollections.defaultdictcreate keys as a side effect of lookups; use them when building nested structures, not when reading them.
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.
Python'NoneType' object is not subscriptable
Fix 'NoneType' object is not subscriptable: common causes (list.sort(), dict.get, re.match, JSON null), reading the traceback, and catching it with mypy.
PythonTwo-dimensional arrays
Create 2D arrays in Python with nested list comprehensions or NumPy, avoid the [[0]*n]*m aliasing bug, and index, iterate, transpose and slice correctly.
PythonCount word occurrences
Count how often words appear in a Python list with list.count, collections.Counter, dict and defaultdict loops, normalization of case and punctuation, and pandas.