文章目录
- 一、先讲一个熟悉的场面
- 二、text=True 一次做了三件事
- 三、五种处置,五个结局
- 四、为什么你的 mac 多年没炸
- 五、正解:三种场景三选一
- 六、30 秒带走版
一、先讲一个熟悉的场面
脚本在开发机上跑了一年没事,扔给同事的 Windows 机器,第一次运行就炸:
importsubprocess r=subprocess.run(["some_tool","status"],capture_output=True,text=True)print(r.stdout)UnicodeDecodeError: 'utf-8' codec can't decode byte 0xcf in position 0: invalid continuation bytetext=True,一个看起来人畜无害的参数。它做的事情远比「把输出变成字符串」多——它偷偷沿用了系统 locale 编码。你的开发机是 UTF-8,同事的机器是 cp936(简中 Windows),而那个外部工具输出的是 GBK 字节:解码器和字节流对不上,当场崩。本篇把这件事的全部切面实测一遍。
二、text=True 一次做了三件事
大多数人只记得住第一件(图一右侧):
- 解码:stdout/stderr 按
locale.getpreferredencoding(False)解码——3.3 版本起就是这个行为,官方文档原话。简中 Windows 上是 cp936,UTF-8 locale 的机器上是 utf-8,同一份代码在两种机器上有两种命; - 换行:universal newlines 同步生效——
\r\n与\r统一成\n。实测同一个子进程输出:text 模式拿到两个以换行结尾的行(line1、line2),binary 模式拿到的字节里\r\n原样保留。这个转换经常被感谢(Windows 输出不再带尾巴),但也经常被忘(它同时发生在 stderr 上); - 输入:
input=传字符串时,按同一套编码编码后写进子进程 stdin——编码不一致时崩在发送端,错误栈指向communicate(),排查时很容易看错方向。
还有个冷知识:stderr 走同一套规则。子进程往 stderr 写一句 GBK 编码的错误信息,父进程text=True一样崩——错误信息本身能把你的程序搞死(实测0xb4位置 0)。子进程明明失败了,你却连它的错误日志都没读到。
三、五种处置,五个结局
构造一个输出 GBK 字节的子进程(模拟简中 Windows 上的外部工具),同一份输出过五种处置(图二):
importsubprocess,sys cmd=[sys.executable,"-c","import sys; sys.stdout.buffer.write('香港深圳'.encode('gbk'))"]# 1. 什么都不加:跟随 locale(本机 utf-8),崩try:subprocess.run(cmd,capture_output=True,text=True)exceptUnicodeDecodeErrorase:print("崩:",e)# 0xcf 位置 0# 2. errors=replace:不崩,但内容失真r=subprocess.run(cmd,capture_output=True,text=True,errors="replace")print(repr(r.stdout))# 4 个汉字 → 7 个问号字符# 3. errors=ignore:不崩,静默全丢r=subprocess.run(cmd,capture_output=True,text=True,errors="ignore")print(repr(r.stdout))# 空字符串# 4. 显式声明:知道对方输出什么编码r=subprocess.run(cmd,capture_output=True,text=True,encoding="gbk")print(r.stdout)# 香港深圳# 5. binary 模式:字节自主权拿回来r=subprocess.run(cmd,capture_output=True)print(r.stdout.decode("gbk"))# 香港深圳实测结果摆在一起看(图二):什么都不加,UnicodeDecodeError;errors 取 replace,4 个汉字变成 7 个问号字符(U+FFFD)——不崩,但内容已经是脏的;errors 取 ignore 更狠,4 个汉字变成空字符串——静默全丢,连个提示都没有。后两种比崩溃更危险:崩溃会被发现,失真不会。
四、为什么你的 mac 多年没炸
不是代码对,是环境凑巧。UTF-8 locale 的机器上,默认解码器恰好是 UTF-8,而绝大多数现代工具输出恰好也是 UTF-8——两个「恰好」叠了很多年。这也是「跨平台」三个字最贵的地方:它不出现在功能测试里,只出现在别人的机器上。更麻烦的是 CI 也可能骗你——容器镜像的 locale 常常被设成 C.UTF-8 或 POSIX,看起来人畜无害,某天换成一台没设 locale 的基础镜像,同样的代码立刻现原形。判断当前机器到底用什么解码,一条命令就够:读locale.getpreferredencoding(False)的值,别看代码里写没写。直到某天:接了一个输出 GBK 的 legacy 工具,或者脚本被复制到简中 Windows 上跑,或者 CI 换了基础镜像。这类问题在代码 review 里永远看不出来,因为它在你的机器上复现不了。
版本维度还有一个正在走到的变化:PEP 686 已随 Python 3.15(RC 2026-08-04)落地——text=True 无显式 encoding 时默认 UTF-8。也就是说,3.15 上「什么都不加」的默认行为会变:Windows 上原本读 cp936 的代码,升级后开始按 UTF-8 读——一部分代码被默默修好,另一部分(真的在依赖系统编码的)会悄悄改变行为。升级前用-X warn_default_encoding扫一遍调用点,比事后抓瞎便宜。
五、正解:三种场景三选一
总规则一条:凡是capture_output=True配text=True,就把 encoding 写齐(图三)。写哪个,取决于你和对方的关系:
- 知道对方输出什么编码:encoding 声明为 utf-8。自研 CLI、开源工具(git、node、ffmpeg 这类)基本都是 UTF-8,一句声明,跨平台行为拉平;
- 必须跟随系统编码:encoding 声明为 locale(3.10+ 引入,3.11 修好了 UTF-8 模式下的行为)。要读 Windows 记事本类工具、公司 legacy 内部工具的输出时用它——注意这是「显式跟随」,和「不写参数被动跟随」是两回事:前者是决策,后者是赌;
- 要完全字节自主权:binary 模式(不写 text),自己 decode、按行扫、处理二进制协议或超大流。BOM、换行符、编码探测,全都回到你手里。
顺带把排查工具记住:locale.getencoding()(3.11+)读当前 locale 的真实编码,比getpreferredencoding()诚实(后者在 UTF-8 模式下会撒谎,永远返回 UTF-8)——这个知识点和之前那篇默认编码篇是同一族。两个函数一字之差,排查时用错方向会得出完全相反的结论。
还有一个组合场景值得点名:subprocess 套 subprocess。外层脚本用 UTF-8 locale 起一个内层进程,内层再调外部工具,错误会在最外层的communicate()上炸,栈里却看不到真正吐出非 UTF-8 字节的那一层。多层调用时,把 encoding 声明写在最靠近数据源的那层,别只写最外层——这和日志里加字段是一个道理:离真相越近越好。
六、30 秒带走版
text=True做了三件事:按 locale 解码、换行转\n、输入按同编码写 stdin——不只「变字符串」;- 默认解码器是
locale.getpreferredencoding(False):UTF-8 机器与简中 Windows(cp936)两种命,开发机复现不了是常态; - 实测五结局:默认崩 / replace 失真(4 汉字→7 问号)/ ignore 静默全丢(→空串)/ 显式 encoding 完整 / binary 自主;
- 不崩的两种比崩溃更危险——脏数据和空数据不会报警;
- stderr 同样按这套规则解码:错误信息本身能让程序崩;
- 总规则:capture_output 配 text=True 时显式写 encoding(utf-8);跟随系统用 locale 取值;要自主权用 binary 自己 decode;
- 3.15(PEP 686)起 text=True 默认变 UTF-8——升级前
-X warn_default_encoding扫一遍调用点; - 读真 locale 编码用
locale.getencoding()(3.11+),别信 UTF-8 模式下的getpreferredencoding()。
本文全部实验在 Python 3.13(UTF-8 locale)本地实测,GBK 输出由受控子进程构造(模拟简中 Windows 外部工具行为),Windows 侧结论按官方文档口径与受控模拟给出;文中未运行真实 Windows 环境。