☰
Python JSON处理全攻略:从解析到流式处理,避开四大经典坑
2026/10/1 20:39:21 网站建设 项目流程

最近处理一个数据迁移的小任务,运营给了我一整张几十MB的JSON文件,里面是好几层嵌套的用户行为记录。我用Python打开处理,结果第一版脚本直接翻车——不是语法错,而是json.loads读出来的对象和我想象中完全不一样。类似的问题我几乎每年都会在社区里看到三五次:有人把JSON字符串直接当dict下标用,有人导出文件全是\u开头的中文,有人在接口返回里死活取不到想要的字段。

所以我打算把平时处理json的整套工作流原原本本记下来,从Python标准库json模块的四个核心函数(json.load、json.loads、json.dump、json.dumps)讲起,一直延伸到文件读写、接口对接、嵌套数据提取、大文件流式处理,最后再把高频翻车点集中复盘一遍。这篇文章适合刚入门Python、需要对接接口或者处理配置文件的同学看,也适合已经写过一阵子但总在小细节上卡壳的人。

1. 把JSON当字典用之前,先认清三件事

1.1 JSON和Python字典:长得像,但不是一回事

JSON的全称是JavaScript Object Notation,最初是给JavaScript用的数据交换格式。因为设计得足够简单,后来几乎所有语言都把它当成通用的数据交换标准。它的语法确实和Python字典很像,但本质上JSON只是一个字符串,一种文本格式。Python字典是运行在内存里的对象,两者之间隔着一道"解析"的步骤。

这个差异看起来简单,实际坑了很多人。最常见的翻车现场:从文件里读了一段内容,打印出来是{'name': '张三'},于是直接写data['name'],结果报TypeError: string indices must be integers, not str。原因很简单,你手里拿到的还是字符串,不是字典。

JSON类型和Python类型的对应关系大概是这样的:

JSON类型Python类型说明
objectdict键值对结构
arraylist有序数组
stringstr字符串
numberint / float整数或浮点数
true / falsebool注意小写开头
nullNone空值

记住这张表,后面很多报错都能一眼定位。

1.2 为什么用标准库json,而不是正则硬抠

我见过有人用正则表达式从JSON文本里提取字段,理由是"std json库我还要查参数,不如正则熟"。这个想法值得商榷。JSON的嵌套结构、转义字符、中文字符编码组合起来,正则写起来不仅长,还非常容易漏边界。比如字符串里包含引号、逗号、花括号时,纯正则基本是灾难。

Python标准库里的json模块是C语言实现的核心逻辑,性能好、解析稳定,更重要的是它帮你处理了所有嵌套和转义细节。调用方式也极其简单,就四个函数:

  • json.load:从文件对象读取JSON
  • json.loads:从字符串读取JSON
  • json.dump:把Python对象写入文件
  • json.dumps:把Python对象转成JSON字符串

load和loads的区别,dump和dumps的区别,就是一个多了一个s,s代表string。搞懂这四个函数,日常80%的场景就能覆盖了。

1.3 先确认数据的"身份"再动手

处理任何JSON数据之前,我建议你先做一步:确认当前数据到底是什么类型。别急着写业务逻辑,先跑一段诊断代码,两分钟不到,能省下后面大量的排查时间。

import json from pathlib import Path source = Path('data.json').read_text(encoding='utf-8') print(type(source)) # <class 'str'>,说明是字符串 print(source[:200]) # 看一眼文本开头 data = json.loads(source) print(type(data)) # <class 'list'> 或 <class 'dict'>

如果数据是二进制bytes,记得先decode成字符串,比如source.decode('utf-8')。养成这个习惯之后,你会发现很多"莫名其妙"的报错,其实都出在最开始这一步上——你以为在操作字典,实际上在操作字符串。

2. 文件场景:json.load和json.dump的正确打开方式

2.1 读取JSON文件的标准姿势和编码细节

读取JSON文件,最标准的写法是用上下文管理器配合json.load:

import json with open('config.json', 'r', encoding='utf-8') as f: data = json.load(f)

很多初学者会漏掉encoding='utf-8'。Windows环境的默认编码经常是GBK,如果JSON文件里有中文,直接open('config.json', 'r')读取很可能抛UnicodeDecodeError。加了encoding='utf-8'可以规避一部分问题。

但这里还有一个更隐蔽的坑:文件如果带了BOM头(Byte Order Mark,常见于Windows记事本另存为UTF-8的文件),即便指定utf-8也可能在解析第一个键时报错,错误信息大概是JSONDecodeError: Unexpected UTF-8 BOM。解决办法是把编码参数改成utf-8-sig:

with open('config.json', 'r', encoding='utf-8-sig') as f: data = json.load(f)

utf-8-sig会自动吃掉开头的BOM标记,实际上对不带BOM的文件也完全兼容。所以如果你不确定文件来源,直接用utf-8-sig是最稳的。

读取阶段另外一个高频报错是json.JSONDecodeError。常见的触发原因有:JSON文件末尾多了个逗号、文件里有//注释、文件内容被截断。JSON标准本身不允许注释和尾逗号,所以能用Python标准json库直接解析的文件,必须是严格合规的JSON。拿到的文件不合规,优先和源头沟通修正格式,别自己造解析器。

2.2 写出文件时几个容易忽略的参数

写出JSON文件,我通常是这个模板:

import json config = { "version": "2.1", "timeout": 30, "retry": True } with open('config.json', 'w', encoding='utf-8') as f: json.dump(config, f, ensure_ascii=False, indent=2)

ensure_ascii=False是最关键的参数之一。不加的话,输出的JSON文件里中文会变成\u4e2d\u6587这种转义序列,虽然语义上没错,但肉眼检查、命令行grep都很痛苦,而且和很多下游工具交接也不方便。加上之后,文件里直接显示中文,可读性好很多。

indent=2用来控制缩进,生成的文件更接近"给人看的配置"而不是"给机器压的包"。如果你要传输给前端或者存日志,希望体积更小,可以反过来设置separators=(',', ':')去掉多余空格,把文件压到最小。

还有个细节容易被忽略:直接对一个正在运行中的配置文件执行json.dump覆盖写入,万一程序中途崩溃,文件会损坏。稳妥的做法是"先写临时文件,再原子替换":

import json import os tmp_path = 'config.json.tmp' target_path = 'config.json' with open(tmp_path, 'w', encoding='utf-8') as f: json.dump(config, f, ensure_ascii=False, indent=2) os.replace(tmp_path, target_path)

os.replace在大多数系统上是原子操作,要么成功替换,要么保留原文件,不会出现写到一半文件截断的情况。这个习惯在写自动化脚本时尤其有用。

3. 接口场景:loads与dumps才是爬虫和API对接的主战场

3.1 从response.text到Python对象的完整链路

做爬虫或者对接第三方API时,你拿到的响应本质上是一段JSON文本,需要转成Python对象才能横向处理。很多人用requests库的response.json()直接转换,这个方法确实方便,但遇到编码不正确的接口就会出问题,最常见的症状是解析纯ASCII内容成功,一旦接口返回中文就抛异常。

我更推荐显式地处理:

import json import requests resp = requests.get('https://api.example.com/recent', timeout=10) resp.raise_for_status() text = resp.content.decode('utf-8') data = json.loads(text)

先拿resp.content拿到底层bytes,再用decode('utf-8')自己控制解码,最后交给json.loads解析。这样做虽然多两行代码,但把"网络传输编码"和"JSON解析"两个环节彻底分开了,哪一步出问题都容易定位。

有的接口返回的JSON最外层不是对象而是数组,也就是[{...}, {...}]这样的结构。这种情况下json.loads得到的直接就是一个list,可以立刻遍历。不要下意识以为JSON一定以花括号开头,以中括号开头的JSON同样合法。

3.2 构造请求参数时dumps的微妙之处

很多接口要求请求体是JSON字符串,尤其是POST接口。requests库设计得比较好,requests.post(url, json=payload)会帮你把Python dict序列化成JSON字符串并设置Content-Type: application/json。但有的场景下你还是得手动json.dumps:

  • 接口签名要求对JSON字符串做摘要校验
  • 业务需要把JSON字符串作为另一个字段嵌套进JSON里
  • 某些网关只认字符串形式的body

手动序列化请求体时,有个坑是序列化结果不稳定。同一个dict,键顺序不同,生成的JSON字符串就不同。如果你的接口需要计算签名,这个差异会直接导致签名校验失败。解决办法是固定键顺序:

import json import hashlib payload = {"timestamp": 1710000000, "app_id": "demo", "amount": 100} canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False) sign = hashlib.md5(canonical.encode('utf-8')).hexdigest()

sort_keys=True保证所有键按字母序排列,separators=(',', ':')去掉多余空格,确保同样的业务数据任何时候序列化结果都完全一致。这是做接口签名的常规操作,能省掉大量联调时的"为什么签名老不对"类问题。

3.3 接口返回结构复杂时,先当一次"结构侦探"

联调新接口,我最先做的事情不是写业务逻辑,而是把返回的JSON结构完整看一遍。但如果直接在控制台打印整个response,几十层嵌套能刷好几个屏幕,还容易把终端搞得乱七八糟。聪明的做法是写一个小函数,只打印结构骨架,不打印具体值:

def show_structure(obj, depth=0, max_depth=6): if depth > max_depth: return if isinstance(obj, dict): for k, v in obj.items(): print(' ' * depth + f"{k}: {type(v).__name__}") show_structure(v, depth + 1, max_depth) elif isinstance(obj, list) and obj: print(' ' * depth + f"[0]: {type(obj[0]).__name__}") show_structure(obj[0], depth + 1, max_depth) else: print(' ' * depth + f"{type(obj).__name__}")

这个函数输出类似:

code: str data: dict total: int list: list [0]: dict order_id: str amount: float

一眼就能看出数据长什么样,字段在哪个层级,类型对不对。我把这个函数当成通用调试工具放在自己的util模块里,每次对接新接口都先用它打一个结构图,比翻接口文档还快。

4. 嵌套再深也不怕:复杂JSON的数据定位与提取

4.1 链式取值为什么容易连环爆炸

多层嵌套的数据结构,取值时最直觉的写法是data['results'][0]['detail']['price']。这在数据格式稳定的时候没问题,可一旦中间某个环节缺失,就会连环报错:

  • 键不存在,抛KeyError
  • 索引越界,抛IndexError
  • 预期是dict结果拿到的是list,抛TypeError

大接口的数据偶尔缺字段太常见了。为了不被这种问题中断,我习惯写一个安全取值函数,把路径作为参数传入:

def safe_get(data, path, default=None): cur = data for key in path: if isinstance(cur, dict) and isinstance(key, str): cur = cur.get(key) elif isinstance(cur, list) and isinstance(key, int) and 0 <= key < len(cur): cur = cur[key] else: return default if cur is None: return default return cur

调用方式:

price = safe_get(data, ['results', 0, 'detail', 'price'], 0)

路径里混了字典键和列表索引都可以处理。默认值按业务场景传0、空字符串还是空列表,自己把握。这个函数帮我处理了大量脏数据,属于性价比非常高的工具代码。

4.2 不知道字段藏在哪一层?写个搜索工具

接口文档写得不全,或者数据是第三方导出的,你根本不知道某个字段藏在嵌套结构的第几层。这时候与其层层打印碰运气,不如直接写一个递归搜索函数:

def find_all_keys(obj, target, path=''): results = [] if isinstance(obj, dict): for k, v in obj.items(): new_path = f"{path}.{k}" if path else str(k) if k == target: results.append((new_path, v)) results.extend(find_all_keys(v, target, new_path)) elif isinstance(obj, list): for i, item in enumerate(obj): results.extend(find_all_keys(item, target, f"{path}[{i}]")) return results

比如数据结构里藏着user_id,但你不知道位置,直接:

for path, value in find_all_keys(data, 'user_id'): print(path, value)

它会告诉你order.user_id、log[2].user_id这些完整路径和对应值。有时候一个key会在多个地方出现,这个函数能全部找出来,比看文档还直观。

4.3 把JSON数组当成小型数据库筛选统计

接口返回的json数组,通常是一组相同结构的对象,比如订单列表、用户列表。对这种数据,Python列表推导式是最好的筛选工具:

orders = json.loads(resp.text)['data']['list'] paid_orders = [o for o in orders if o.get('status') == 2] high_value = [o for o in paid_orders if o.get('amount', 0) > 1000]

条件统计也很顺手:

success_count = sum(1 for item in orders if item.get('success')) total_amount = sum(item.get('amount', 0) for item in orders)

再复杂一点,按字段分组统计,用dict手动分组就够了,不需要引入pandas:

grouped = {} for item in orders: status = item.get('status') grouped.setdefault(status, []).append(item)

这类操作在我的日常里非常高频。接口数据拿到手,先筛再算,逻辑清晰,性能也足够。如果数据量大到几十万条以上,再考虑引入pandas或数据库,小规模数据用列表推导式反而更轻便。

4.4 进阶工具:JSONPath快速提取

如果嵌套层级太深,你只想提取满足某个路径模式的一批值,可以上JSONPath,语法类似XPath。安装扩展库后可以直接用:

pip install jsonpath
from jsonpath import jsonpath # 提取所有商品的价格 prices = jsonpath(data, '$..price') # 提取前两个订单的ID ids = jsonpath(data, '$.orders[0:2].order_id')

返回结果是list,匹配不到返回False,要注意判断。JSONPath适合数据格式相对稳定、但路径很深的场景,比手写多层循环简洁得多。也有命令行工具jq可以做类似事情,处理本地文件很方便,我偶尔用来快速预览,不过项目代码里还是优先用Python脚本,可维护性更好。

5. 大JSON文件与流式处理:别一口气吃成胖子

5.1 JSON Lines格式:逐行读取才是正道

在做数据交换时经常遇到一种特殊格式:文件后缀是.jsonl或者.ndjson,一行一个独立的JSON对象,而不是一个巨大的数组包住所有对象。这种设计的好处是每一行都能独立解析,读多少处理多少,不需要加载整个文件。

处理这类文件,正确的方式是逐行读取:

def read_json_lines(path): with open(path, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue yield json.loads(line)

这是一个生成器,每次yield一个对象。调用方逐个处理,不需要把全部对象同时放内存里:

records = read_json_lines('logs.jsonl') for record in records: process(record)

如果你把整个文件直接json.load(f),最外层不是数组就会报错,是数组也会全部载入内存。面对几个GB的日志文件,轻则卡顿,重则直接OOM。

5.2 标准JSON大数组怎么处理:ijson流式解析

但现实是很多系统导出的还是标准JSON大数组,整个文件就是一个[...]几百MB甚至几个GB。这时候json.load会把整个数组一次性解析进内存,Python对象的内存占用往往比文件体积还大几倍,机器稍微吃紧就扛不住。

推荐用ijson做流式解析,它像拉水管一样一点一点往外吐数据,不会把你的内存撑爆:

pip install ijson
import ijson with open('huge.json', 'rb') as f: for item in ijson.items(f, 'records.item'): process(item)

'records.item'表示外层数组字段名为records,逐个取出其中的item对象。文件格式如果是顶层就是数组,可以写成'item'。ijson的解析速度虽然比标准json模块慢一些,但省下的内存非常可观,几GB的文件也能处理。

5.3 处理大数据的通用心态:能流则流,别全装

我自己的经验是:处理任何大数据量的JSON,先问自己"我真的需要全部数据同时存在内存里吗"。大部分业务场景都是按条处理——清洗、转换、汇总、落库,根本不需要把全部数据集合在内存里。

如果是需要全局排序或者全量去重,那是另一回事,要考虑外排或数据库方案。如果只是逐条处理,生成器加流式解析就是最优雅的解法。写成一个生成器,既可以用for循环遍历,又能配合itertools.islice只取前N条测试,灵活度非常高。

6. 我在JSON处理上踩过的四个经典坑

6.1 中文全部变成\uXXXX

当年第一次用json.dump写配置,打开文件看到满屏\u4e2d\u6587时整个人都懵了。后来才明白,默认json模块在序列化时会把非ASCII字符全部转义,保证生成的JSON文本百分之百是纯ASCII。这在跨语言传输时是好设计,但人读起来就是灾难。

解决办法前面已经提过,加ensure_ascii=False。需要注意,如果文件是给别人消费的,尤其是老旧的JavaScript或某些数据库工具,可能反而期望\u转义,这时候就别关。看具体场景来,不要养成"永远加"或"永远不加"的坏习惯。

6.2 datetime和自定义对象序列化失败

给接口传参数,payload里有个datetime对象,json.dumps直接抛TypeError: Object of type datetime is not JSON serializable。这个报错很多人遇到过,解决办法是用default参数,让json模块遇到不认识的对象时调自定义函数:

from datetime import datetime def default_convert(obj): if isinstance(obj, datetime): return obj.strftime('%Y-%m-%d %H:%M:%S') if hasattr(obj, 'isoformat'): return obj.isoformat() raise TypeError(f'无法序列化类型 {type(obj)}') data = json.dumps(payload, default=default_convert, ensure_ascii=False)

这个default钩子类似"兜底翻译官",遇到所有未知类型都会来问它。比到处手动转字符串省事得多。

6.3 布尔值大小写和null空值

JSON和Python在布尔值写法上不同,JSON里是true、false,Python里是True、False。如果自己拼JSON字符串,拼成Python的写法,对方的解析器直接不认。用json.dumps来生成就不会犯这种错。

再说null,json.loads('null')的结果是None,这是正常的。真正容易踩的是接口返回字段值为null,而你的代码写成if data['field']:,明明有值的情况可能没问题,但值为空字符串或null时都走了False分支。该用is not None判断的时候,就老老实实用is not None,别图省事靠真值判断。

6.4 重复键被静默覆盖

JSON规范里其实不鼓励重复键,但实际数据里可能会出现,比如手写的配置文件里同一个key写了两次。默认json.loads会静默保留最后一个值,前面的直接丢。如果你怀疑数据里有重复键,想把它排查出来,可以用object_pairs_hook钩子:

def check_dup_keys(pairs): d = {} for k, v in pairs: if k in d: print(f'发现重复键: {k},值将从 {d[k]!r} 覆盖为 {v!r}') d[k] = v return d data = json.loads(text, object_pairs_hook=check_dup_keys)

这个钩子会在每个dict对象构建前把所有键值对列表暴露给你,加一点打印或告警逻辑,重复键就无所遁形了。数据是从不可信的来源来时,这个检查特别有用。

还有一个坑和浮点数精度有关。JSON里的数字可能超过JavaScript安全整数范围,Python int本身不怕大整数,但如果你把很大的数转给浏览器或者某些只支持64位整数的下游系统,精度可能丢。处理金融类数据时,优先用字符串保留原始表示,别用float接,否则分秒之间钱就差一分两分都可能。

最后分享一个我的个人习惯:拿到新接口的数据后,我在写任何业务代码前,都会先保存一份原始JSON原文,再保存一份结构图。原文留着回溯问题,结构图留着写字段路径。真正处理JSON时,最耗时间的往往不是写代码,而是猜数据结构。有了结构图,写代码就跟填空题一样简单。这套工作流我用下来,翻车概率低了很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询