接口返回的数据经常出现多层嵌套结构,例如用户信息里包含订单列表,订单列表里又有商品明细。如果直接把这种JSON用pandas.read_json读进来,会得到列中套字典或列表的DataFrame,无法直接做聚合、筛选和可视化。要发挥Pandas的向量化能力,必须先做扁平化处理。

Pandas提供了一个专门函数json_normalize,在pandas.json_normalize模块中,0.25版本之后也可以直接通过pd.json_normalize调用。它能将半结构化JSON递归展开成平面表,但很多人只用默认参数展开一层,遇到下一层数组时就不知道如何处理。接下来从参数机制讲起。
一、理解json_normalize的展开机制
json_normalize的核心任务是把带嵌套字典的数据拆成列名带前缀的平面表。它接受一个字典列表或字典作为输入,默认会递归展开所有字典字段,并用sep参数指定的分隔符连接父键和子键,默认是英文句点。例如一个对象里包含profile.city,默认展开后列名就是profile.city。
问题出在列表字段。默认情况下,json_normalize不会自动进入列表内部展开,而是把整个列表作为一个单元格保留。要展开列表,需要使用record_path参数指定列表所在的路径。比如数据是[{"user_id":1, "orders":[...]}],要展开orders,可以设置record_path=["orders"]。同时还要通过meta参数把上层的用户信息携带到每一行,否则展开后只剩下订单字段,用户ID会丢失。
下面这段代码展示了默认展开和指定record_path的差异:
import pandas as pd
data = [
{
"user_id": 1,
"profile": {"city": "Beijing", "level": 3},
"orders": [
{"order_id": 1001, "amount": 99.5, "items": [{"sku": "A1", "price": 20.5}]},
{"order_id": 1002, "amount": 120.0, "items": [{"sku": "A2", "price": 60.0}, {"sku": "A3", "price": 60.0}]}
]
}
]
# 默认展开,orders列仍然是列表
df = pd.json_normalize(data)
print(df)
# 指定record_path展开订单列表
df_orders = pd.json_normalize(
data,
record_path=["orders"],
meta=["user_id", ["profile", "city"], ["profile", "level"]],
record_prefix="order_",
errors="ignore"
)
print(df_orders)
运行第一个打印会看到profile.city和profile.level两列,而orders列仍然是列表对象。第二个打印则会把每个订单拆成单独一行,并保留user_id和城市信息。这里meta中的嵌套路径写法["profile", "city"]表示从顶层开始逐级定位到城市字段,比直接写"profile.city"更不容易受分隔符干扰。
record_prefix和meta_prefix用于给展开后的列名加前缀,避免订单字段和用户字段重名。errors参数建议在数据字段不稳定时设置为"ignore",这样某个列表元素缺少某个字段时不会直接报错,而是返回NaN。不过要注意,errors="ignore"只作用于meta路径中的缺失字段,如果record_path本身不存在,依然会抛异常。
二、处理列表嵌套列表的实战展开
真正麻烦的是两层甚至三层列表嵌套。比如用户有多个订单,每个订单里还有一个商品明细列表items。如果直接设置record_path=["orders"],得到的是订单级DataFrame,但items列仍然是列表,里面每个元素都是字典。此时不能继续用同一个调用去指定items,因为record_path描述的路径无法穿过已经被拆开的订单列表。
一种稳定做法是分步处理:先用json_normalize展开订单列表,得到订单表;然后对items列执行explode,让列表中的每个商品字典成为一行;最后再对这些字典调用一次json_normalize,拆出商品字段,并把结果拼回原表。这里的关键是explode只负责把列表拉长,不会解析字典内容,所以第二次json_normalize必不可少。
# 第一步:展开orders,保留订单元信息
orders_df = pd.json_normalize(
data,
record_path=["orders"],
meta=["user_id", ["profile", "city"]],
record_prefix="order_"
)
# 第二步:explode items列,使每个商品字典独立成行
orders_exploded = orders_df.explode("items").dropna(subset=["items"]).reset_index(drop=True)
# 第三步:对items字典列再做json_normalize
items_df = pd.json_normalize(orders_exploded.pop("items")).add_prefix("item_")
flat_df = pd.concat([orders_exploded, items_df], axis=1)
print(flat_df)
这段代码中,explode之后需要调用reset_index(drop=True),否则索引会重复,后续拼接可能出现对齐错误。对于items列中可能存在的None值,先用dropna(subset=["items"])过滤掉,避免第二次json_normalize处理空值时报错。如果商品明细缺失但你又想保留订单行,可以在过滤前把None替换成空字典,配合errors="ignore"生成全NaN的商品列。
这种分步方案适合列表层级固定、字段结构比较清晰的场景。当层级超过两层时,可以继续重复“explode再normalize”的过程,但代码会逐渐变得冗长。下一节会给出一个递归函数,把任意深度的嵌套结构一次性拍平。
三、编写递归扁平化函数处理任意层级
递归扁平化的思路是遍历对象,遇到字典就继续进入子键,遇到列表就按索引进入每个元素,遇到标量就把它作为最终列的值。这样处理之后,列名中会带上列表索引,例如orders.0.items.1.price。对于单条记录或少量记录,这种宽表非常直观,配合pd.DataFrame.from_records可以快速生成DataFrame。
from collections.abc import MutableMapping
def flatten_json(y, parent_key="", sep="."):
items = {}
if isinstance(y, MutableMapping):
for k, v in y.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
items.update(flatten_json(v, new_key, sep=sep))
elif isinstance(y, list):
for i, item in enumerate(y):
new_key = f"{parent_key}{sep}{i}"
items.update(flatten_json(item, new_key, sep=sep))
else:
items[parent_key] = y
return items
nested = {
"user_id": 1,
"profile": {"city": "Beijing", "level": 3},
"orders": [
{"order_id": 1001, "items": [{"sku": "A1", "price": 20.5}]}
]
}
flat_row = flatten_json(nested)
print(pd.DataFrame([flat_row]).T)
这个函数用collections.abc.MutableMapping判断字典类型,比直接判断dict更稳妥,因为它能覆盖OrderedDict等映射类型。列表使用enumerate生成索引,父键和当前键之间用sep连接,默认也是句点。最终返回一个扁平的字典。对多条记录,可以用列表推导式逐条调用flatten_json,再传给pd.DataFrame.from_records。由于from_records会取所有记录字段的并集作为列,某些记录缺少字段时会自动填NaN,很适合参差不齐的日志数据。
递归函数虽然直观,但也有明显缺点:列表长度不一致时列会大量膨胀,比如一次接口返回100条记录但其中一条订单特别多,就可能多出几十列。另外,它把列表索引固化到列名中,不利于后续按订单维度继续分析。因此通常的做法是:如果目标是做用户级特征,可以用递归宽表;如果目标是订单级或商品级明细,还是使用json_normalize配合explode更合适。
四、常见异常与性能取舍
在处理真实业务JSON时,最常见的问题是字段缺失和类型不一致。某个用户的orders可能是列表,另一个用户可能是None,直接调用json_normalize就会报TypeError或AttributeError。稳妥的做法是在读取后先做一次轻量清洗:把None列表替换成空列表,把字符串类型的JSON字段用json.loads解析成对象,再进入扁平化流程。字段名中包含点号时,建议把sep改成下划线,否则json_normalize可能把它误判成路径分隔符。
另一个容易被忽视的问题是笛卡尔积。如果同一层存在两个列表字段,比如每个用户有多个订单和多个优惠券,直接把两列各自explode,会使行数从N变成N乘以M,优惠券与订单错误组合。这种情况应该分别展开成两张表,再通过主键关联,或者先聚合其中一个列表再展开另一个。性能方面,json_normalize在展开外层结构时走的是pandas内部实现,速度通常优于纯Python递归;但当列表嵌套很深或字段极多时,递归函数会频繁创建字典,数据量超过十万条就会明显变慢。此时建议先抽样验证逻辑,再把稳定部分固化成json_normalize调用,减少Python层循环。
最后,扁平化之后的字段经常是字符串类型,例如时间、金额都可能以字符串形式存在。可以继续用pd.to_datetime和pd.to_numeric做类型转换,并在转换前检查异常值。整个流程可以封装成一个读取、清洗、扁平化、转换四步的预处理函数,方便在不同数据源之间复用。
Python Pandas嵌套JSON数据扁平化修改时间:2026-09-20 20:13:22