我见过太多次同样的场面:一份脚本在自己机器上跑得好好的,拷到同事电脑上直接甩一句SyntaxError: Non-UTF-8 code starting with '\xe4' in file demo.py on line 3。然后有人翻出老文章,在文件头上加了一行# -*- coding: utf-8 -*-,结果还是报错——因为那行被加到了第三行。还有一类更隐蔽的情况:源码没报错,但print出来的中文是乱码,于是又回头去改那行 UTF-8 声明,改了半天毫无效果,因为问题根本不在源码编码这一层。Python 里的 UTF-8 声明方式看起来只是"一行注释",实际上它牵扯到源码编码、解释器默认编码、终端与文件 I/O 编码三套完全独立的机制。这篇就把这行声明讲透:有几种写法是真的有效、必须写在第几行、解释器内部是怎么识别它的、Python 3 里到底还要不要写、不写会在什么场景下炸,以及出问题之后按什么顺序排查。刚接触 Python 的朋友可以照着第 3 节的脚本直接验证,带过几年项目的人可以重点看第 4、5 节里那些"声明写了也没用"的坑。
1. 三层编码模型:先搞清楚你在修哪一层
1.1 一行注释为什么能决定程序跑不跑
.py文件在磁盘上就是一串字节。解释器启动时做的第一件事是把它读进内存,然后需要把这串字节切成一个个字符——切分规则由编码决定。如果不知道编码,遇到大于 127 的字节就抓瞎:这个字节是某个汉字的第一字节,还是独立的一个字符?切错了,后面的词法分析全盘崩溃。所以编码信息必须在解析源码之前就拿到,而那时候程序还没开始执行,任何"运行时"手段都救不了场。
其他语言大多把这个信息放在外部:编译型语言靠命令行参数指定输入字符集,虚拟机语言靠启动参数传编码。Python 走了一条不一样的路,把编码信息写在文件内部的一行特殊注释里。好处很明显——文件自描述,跟着文件走,不依赖谁来调用它,也不会因为调用方忘了传参而挂掉。坏处同样明显:它是注释,注释天然给人"可有可无"的心理暗示,再加上它的位置规则很死,稍微挪动一下就完全失效,于是成了新手最容易忽略又最容易踩的地方。
1.2 Python 2 到 Python 3:从必需变成可选
Python 2 时代这行声明是硬性需求。那时候解释器默认按 ASCII 解读源码,源码里只要出现一个中文注释、一个中文文档字符串,甚至一个全角空格,直接SyntaxError。所以老项目几乎每个文件头顶都顶着一行# -*- coding: utf-8 -*-,属于肌肉记忆级别的操作。
Python 3 把默认源码编码改成了 UTF-8,这行声明从"必需"降级为"可选、冗余但无害"。写了不报错,不写也没事。这一变化让很多人以为它彻底没用了,其实不然,下面这几种场景仍然需要它,或者需要你理解它:
| 场景 | 是否必须写声明 | 说明 |
|---|---|---|
| Python 3 源码存为 UTF-8 无 BOM | 不必 | 默认就是 UTF-8 |
| Python 3 源码存为 GBK 等非 UTF-8 | 必须 | 不写就按 UTF-8 解析,必报错 |
| 源码带 BOM | 不建议写 | BOM 优先级更高,共存容易出歧义 |
| 需要兼容 Python 2 的老代码 | 必须 | Python 2 默认 ASCII |
| 团队规范要求统一 | 写 | 一致性比"省一行"重要 |
| 某些静态检查或打包工具链 | 视工具 | 个别工具会读这行做推断 |
我在维护历史项目时的一个实际体会是:声明本身的价值不在于"让 Python 3 跑起来",而在于它是一份显式的意图声明。当你在一个目录里看到八十个文件都写着 UTF-8,突然有一个文件没写、而且它是从外部系统导进来的,那这个文件就值得单独检查一下编码——这是一条非常便宜的信号。
1.3 源码编码、默认编码、I/O 编码的分工
绝大多数编码问题搞混的根源,是把三件完全不相干的事当成了同一件事。我把它们拆开列在这里,建议你也按这个框架去定位问题:
第一层,源码编码。决定解释器怎么读.py文件。最终值由三者竞争决定:文件 BOM 优先级最高,其次是编码声明注释,最后是默认值(Python 3 为 UTF-8)。这一层出问题的症状只有一种——文件根本跑不起来,报SyntaxError。
第二层,解释器的默认编码。Python 3 里sys.getdefaultencoding()稳定返回utf-8,这个值基本动不了,用于str.encode()不传参数时的兜底。真正需要警惕的是locale.getpreferredencoding(False),它反映的是当前系统的区域设置,中文 Windows 上通常是cp936,精简容器里可能是ANSI_X3.4-1968(也就是 ASCII)。open()不写 encoding 参数时,用的就是它。
第三层,I/O 编码。包括标准输出的编码sys.stdout.encoding、文件系统路径的编码sys.getfilesystemencoding()、以及环境变量PYTHONIOENCODING的覆盖值。这一层出问题的症状是"程序跑通了,但输出是乱码或者写出去的文件打不开"。
记住一条判据:能跑但乱码,问题一定在第二层或第三层,回去改源码声明是白费力气。完全跑不起来,才轮到第一层。
2. UTF-8 声明的写法与解析规则
2.1 四种常见写法,哪些真有效
网上流传的写法五花八门,我按"解释器认不认"分成两组。先看有效的:
# -*- coding: utf-8 -*- # coding: utf-8 # coding=utf-8 # -*- coding: utf8 -*-这四种都能被正确识别。前三种是 PEP 263 里给出或暗示过的形式,第四种用utf8这个别名,Python 的编码注册表里utf8、UTF-8、Utf-8、u8都指向同一个编解码器,大小写和连字符都不敏感。
再看几种"看起来很像但其实也能用"的,这是很多人搞错的地方:
# encoding: utf-8 # 有效,因为 "encoding:" 里包含了 "coding:" # -*- encoding: utf-8 -*- # 同样有效是的,# encoding: utf-8是有效的。因为它匹配的不是"整行必须以 coding 开头",而是一个子串匹配——只要这一行里出现coding后面紧跟:或=,就算数,en这两个字母只是被前缀部分吃掉了。所以严格来说,"# encoding: utf-8无效"是个流传很广的误传,它在当前所有 Python 3 版本上都是有效的。不过我还是建议统一写# -*- coding: utf-8 -*-,原因不在解释器,而在人——各种编辑器的识别规则、代码高亮、静态检查工具对标准写法的支持最稳,别为省两个字符去赌工具的实现细节。
至于真正无效的写法,典型的是这几种:
# 我声明一下编码:utf-8 # 中文关键词,无效 # -*- coding utf-8 -*- # 缺少冒号或等号,无效 """ # -*- coding: utf-8 -*- # 放在文档字符串里,无效 """2.2 位置规则:第一行、第二行与注释前提
位置规则比大多数人以为的要严格一点点,也比一些人以为的宽松一点点。准确的规则是这样的:
解释器只检查文件的前两行。第一行如果匹配到编码声明,就用它;如果第一行没匹配到,那么只有当第一行是注释或空行时,才会去看第二行。这句话里的"注释或空行"是关键。也就是说,下面这些情况声明会失效:
import os # -*- coding: utf-8 -*- # 第三行,失效x = 1 # coding: utf-8 # 第二行,但第一行不是注释,失效而下面这些是生效的:
#!/usr/bin/env python # -*- coding: utf-8 -*-# 这一行是注释 # -*- coding: utf-8 -*- # 生效第一行的 shebang 本质上也是注释,所以带 shebang 的脚本把声明放第二行是标准姿势。我给自己的规则很简单:shebang 在第一行,编码声明在第二行,永远不往第三行挪。至于把声明塞进模块文档字符串的写法,那纯粹是给人看的,解释器一个字都不会读。
2.3 解释器是怎么认出来的
想知道底层逻辑,最省事的办法是直接把标准库里的检测函数拿出来用。tokenize.detect_encoding()就是解释器内部用来做这件事的那套逻辑的公开版本,它的匹配正则大致是:
^[ \t\f]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)拆开看几个细节,每一个都对应一类常见困惑:
^[ \t\f]*#要求这一行必须是注释行,行首允许有空格、制表符、换页符。这解释了为什么声明前面可以有缩进(虽然没人在文件头缩进)。.*?coding是惰性匹配,说明coding前面允许有任意内容。这就是# -*- coding和# encoding都能过审的原因,-*-那对符号纯粹是给编辑器看的模式标记(Emacs 的 file-local variables 语法),对 Python 本身没有任何意义。[:=]说明冒号和等号等价,都行。[ \t]*允许冒号后面有空格,所以# coding:utf-8和# coding: utf-8都行。- 末尾那个字符类限定了编码名的字符集,所以
utf-8、utf_8、utf.8这类都能被捕获,但捕获之后还得能在编码注册表里查到,否则报SyntaxError: encoding problem。
顺带说一个边界情况:声明一个非 ASCII 兼容的编码,比如# coding: utf-16,会直接报encoding problem。原因是解释器需要用 ASCII 兼容的单字节编码来做词法分析,UTF-16 里每个 ASCII 字符都夹着零字节,整个解析流程没法工作。所以能写在声明里的编码,实际上被限制在了 ASCII 兼容的那一批(utf-8、gbk、latin-1、shift_jis 等)。
2.4 BOM:藏在文件头的隐形字符
UTF-8 BOM 是三个字节EF BB BF,Windows 上不少编辑器"另存为 UTF-8"时默认会加。对 Python 来说它不是灾难,但它是麻烦。
解释器读到 BOM 会把文件按utf-8-sig处理,也就是说 BOM 会被吃掉,源码正常解析。所以"带 BOM 的 .py 文件能不能跑"这个问题,答案是能。真正的问题出在两个地方:
第一,BOM 和编码声明打架。文件带 BOM,声明又写了# coding: gbk,解释器的检测结果会互相冲突,报错信息里通常带encoding problem字样。这种文件我一般都是直接转成无 BOM 的 UTF-8,不做任何妥协。
第二,BOM 传染到非源码文件。你的配置文件、JSON 数据、CSV 导出,如果是用某个带 BOM 的编辑器或某个导出工具生成的,用 UTF-8 读就会在第一个字符位置多出一个不可见的\ufeff。表现形式非常迷惑:json.load报Unexpected UTF-8 BOM,读取配置的第一个 key 变成"\ufeffname",正则匹配首行总是失败。排查这类问题的诀窍只有一个——别用文本编辑器看,用二进制视角看:
head -c 3 config.json | xxd如果输出是efbb bf,当场结案。
3. 动手验证:检测、扫描与批量转换
3.1 最小复现:三种文件状态下的表现
理论说完了,直接上手做实验,五分钟能把三种状态全部跑一遍。先准备一个内容最简单的文件:
# demo_utf8.py s = "中文测试" print(len(s), s)用iconv生成一个 GBK 版本,模拟从老系统导出来的文件:
iconv -f utf-8 -t gbk demo_utf8.py > demo_gbk.py现在有两个文件,内容等价但字节不同。依次执行,观察结果:
| 文件 | 保存编码 | 有无声明 | 执行结果 |
|---|---|---|---|
| demo_utf8.py | UTF-8 | 无 | 正常输出4 中文测试 |
| demo_gbk.py | GBK | 无 | SyntaxError,提示非 UTF-8 字节 |
| demo_gbk.py 加声明 | GBK | 有# coding: gbk | 正常输出4 中文测试 |
第三种状态的改动就是在demo_gbk.py第一行插入:
# -*- coding: gbk -*- s = "中文测试" print(len(s), s)这一步能跑通,说明声明确实在起作用——同样的字节,加了声明就能解析,说明解释器是照着声明去解码的。这个实验比看十篇文章都直观,建议每个被编码问题折磨过的人都做一遍。
再补一个反向实验:把声明写成# coding: utf-8但文件本身是 GBK,此时解释器会用 UTF-8 去解 GBK 字节,结果要么是SyntaxError(解码失败),要么更阴险——如果那几个字节碰巧也是合法的 UTF-8 序列,你会得到一串完全不相关的乱码字符,程序能跑,但字符串内容全错。后者最难查,因为没有任何报错。
3.2 用 tokenize.detect_encoding 读出真实声明
光靠肉眼看文件头不可靠,尤其是文件多的时候。标准库给了现成工具,三行代码把解释器认定的编码问出来:
import tokenize with open("demo_gbk.py", "rb") as f: enc, lines = tokenize.detect_encoding(f.readline) print(enc) # gbk print(lines) # [b'# -*- coding: gbk -*-\n']注意必须用二进制模式打开,用文本模式打开就已经错了。返回值第一个是编码名,第二个是被消费掉的前一到两行原始字节。这个函数的好处是它和解释器用的是同一套逻辑,所以它给出的答案就是解释器会采用的答案,不存在"工具说 A、解释器认为 B"的分歧。
还有一个更省事的:tokenize.open(),它会自动检测编码并用检测结果打开文件,等于把detect_encoding+open封装了一下:
import tokenize with tokenize.open("demo_gbk.py") as f: print(f.read())处理来路不明的历史脚本时,这个函数能省掉你手动试编码的时间。
3.3 批量扫描整个仓库的编码状况
接手一个老项目,第一件事是摸清楚有多少文件是非 UTF-8 的。下面这个脚本我用了很多年,改动很小,直接能抄:
import tokenize from pathlib import Path def scan(root: str) -> None: total = 0 bad = 0 for p in Path(root).rglob("*.py"): total += 1 with p.open("rb") as f: try: enc, _ = tokenize.detect_encoding(f.readline) except SyntaxError as e: bad += 1 print(f"[解析失败] {p} -> {e}") continue if enc.lower() not in ("utf-8", "utf-8-sig"): bad += 1 print(f"[非 UTF-8] {p} -> {enc}") print(f"共 {total} 个文件,可疑 {bad} 个") scan("./src")几个实操细节值得说清楚。第一,enc.lower()之后要同时把utf-8和utf-8-sig都算作合格,因为detect_encoding对带 BOM 的文件会返回后者,业务上这俩是一回事。第二,SyntaxError一定要捕获,历史文件里出什么奇怪编码都有可能,一个坏文件不该让整个扫描中断。第三,如果你的项目里有.pyi、.pyx或者模板文件,记得把后缀列表扩展一下。
想更进一步,可以再加一段统计"有声明但声明冗余"的文件数量——那些明明存的是 UTF-8,还老老实实写着# coding: utf-8的文件。这类文件本身无害,但如果你打算批量清理,先统计再动手,别凭感觉删。
3.4 批量转码:备份、换行与回滚
确认了有问题的文件之后才是转换。我给自己定的规矩是三条:先备份、只改编码不动换行、动手前确认版本控制是干净的。照着这个思路写的脚本:
import tokenize from pathlib import Path def to_utf8(root: str, dry_run: bool = True) -> None: for p in Path(root).rglob("*.py"): raw = p.read_bytes() with p.open("rb") as f: try: enc, _ = tokenize.detect_encoding(f.readline) except SyntaxError: print(f"[跳过] {p}") continue if enc.lower() in ("utf-8", "utf-8-sig"): continue text = raw.decode(enc) # 用检测出的编码解码 if not dry_run: p.with_suffix(p.suffix + ".bak").write_bytes(raw) # 原样备份 p.write_text(text, encoding="utf-8", newline="") # newline 留空,保留原换行 print(f"[转换] {p} {enc} -> utf-8") to_utf8("./src", dry_run=True)这里有几个地方是踩过坑才写对的。newline=""必须加,否则write_text在 Windows 上会把\n悄悄换成\r\n,几百个文件的换行风格一次全变,代码评审时 diff 一片红,谁也看不清真正的改动。备份文件名用.bak后缀而不是覆盖到别处,转换完确认没问题再统一删,比依赖版本控制更保险——有些老项目连版本控制都没有。
如果你的文件里同时存在"声明了 GBK 但实际是 UTF-8"这种自相矛盾的状态,上面的脚本会在raw.decode(enc)这一步抛异常,这其实是好事,它会逼着你一个一个看一眼,别想着全自动一把梭。历史项目里的编码坑从来不是均匀分布的,总有那么几个文件是人为搞坏的。
4. 运行时编码:open()、标准输出与 errors 策略
4.1 open() 的 encoding 参数不是可选项
这一节是我最想强调的部分。很多人在文件头写了# -*- coding: utf-8 -*-,就以为整个程序的编码都统一成 UTF-8 了。完全不是。那行声明只影响解释器读.py文件本身,对open()的行为一点影响都没有。
open()不写 encoding 时用的是什么?是locale.getpreferredencoding(False)。这个值跟着操作系统和区域设置走:Linux 中文环境常见 UTF-8,中文 Windows 通常是 cp936,某些精简的容器镜像里是 ASCII。也就是说同一份代码,在你本机跑得好好的,扔到容器里读文件就报UnicodeDecodeError: 'ascii' codec can't decode byte。这类 bug 的恶劣之处在于它和代码逻辑无关,本地复现不了,日志里看起来莫名其妙。
所以我的规矩是:所有文本 I/O,一律显式写 encoding,没有例外。包括open()、Path.read_text()、Path.write_text()、codecs.open()、日志 handler、CSV 读写、JSON 读写。多敲这十几个字符,能省掉后面几个小时的排查。
from pathlib import Path # 读取,显式指定 text = Path("data.txt").read_text(encoding="utf-8") # 写入,显式指定 Path("out.txt").write_text(text, encoding="utf-8") # CSV 特别要注意 newline import csv with open("data.csv", "r", encoding="utf-8", newline="") as f: rows = list(csv.reader(f))csv那个newline=""是官方文档明确要求的,不加的话在 Windows 上可能出现空行,或者字段里带换行时解析错位。这一条和编码无关但和换行有关,一并写在这里省得你回头再查。
还有一种情况是文件带 BOM,比如被某表格软件导出过的 CSV。这时候用encoding="utf-8-sig"读,BOM 会被自动吃掉;用utf-8读,第一个字段名前面就会挂一个看不见的\ufeff,然后你的row["name"]永远取不到值。写文件时如果想兼容那些"必须有 BOM 才能正确识别中文"的老软件,也用utf-8-sig,它会主动写上 BOM。
4.2 标准输出与控制台编码
print的中文乱码,跟前两节都没关系,它取决于sys.stdout.encoding,而这个值取决于终端。在中文 Windows 的控制台里,默认代码页是 936,输出到重定向文件时就按这个编码写;在 Linux 的 SSH 会话里,如果LANG没设,可能是 ASCII,print一个中文直接UnicodeEncodeError: 'ascii' codec can't encode characters。
按场景给三个层次的解法:
临时救急,在脚本开头重设标准输出编码,Python 3.7 以上可用:
import sys if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": sys.stdout.reconfigure(encoding="utf-8")进程级解决,用环境变量覆盖,适合容器和 CI:
PYTHONIOENCODING=utf-8 python app.py根治,别依赖标准输出的编码。凡是需要落盘的内容,走文件写入并显式指定 encoding;凡是需要长期保留的日志,用logging.FileHandler(filename, encoding="utf-8")。标准输出只用来给人看,不承担数据传输的职责。这一条我在跨平台项目里执行得最严格,因为控制台编码是外部环境,你控制不了,只能绕开。
4.3 errors 参数的选择与取舍
解码失败时怎么办,由errors参数决定。默认是strict,直接抛异常——这是对的,也是我推荐的默认值。但总有一些场景必须容错,比如处理用户上传的、来源不明的文本。几个取值的实际差异:
| 取值 | 遇到非法字节时 | 适用场景 | 风险 |
|---|---|---|---|
strict | 抛 UnicodeDecodeError | 默认,数据可信 | 一个坏字节毁掉整个流程 |
ignore | 直接丢弃 | 只要统计信息、不关心内容 | 静默丢数据,最难查 |
replace | 替换成?或\ufffd | 展示用、容错优先 | 内容失真但可感知 |
backslashreplace | 转成\xNN转义 | 日志、调试输出 | 长度膨胀 |
surrogateescape | 映射到 U+DC80–U+DCFF | 文件名、命令行参数原样往返 | 不能进数据库/网络传输 |
surrogateescape值得单独说一句。它的用途是"带着原始字节走一圈再还回去",典型场景是文件系统路径和命令行参数——这些数据本来就可能是任意字节序列,你必须能原样传递。Python 在sys.argv和os.listdir()上默认就用这套机制。但千万别拿它处理业务文本,因为它产生的是非法 Unicode 字符,一旦序列化进 JSON 或者写进数据库就会在更远的地方炸掉,那时候排查成本翻十倍。
至于ignore,我的建议是能不用就不用。它把"数据有问题"变成了"数据悄悄少了几个字",线上环境里这种静默失败比直接报错危险得多。真要用,至少配一行日志,把原始字节的长度和解码后的长度对比一下,差多少心里有数。
5. 故障排查实录与工程化收口
5.1 报错速查表
下面这张表是我这些年攒下来的,遇到编码报错先对号入座,能省掉大部分瞎猜的时间:
| 报错信息关键片段 | 属于哪一层 | 常见原因 | 处理方向 |
|---|---|---|---|
Non-UTF-8 code starting with | 源码编码 | 文件是 GBK 等非 UTF-8,且没声明 | 加声明或转码 |
encoding problem: xxx | 源码编码 | 声明了非 ASCII 兼容编码,或 BOM 与声明冲突 | 改成 UTF-8,去掉 BOM |
SyntaxError指向含中文的行 | 源码编码 | 声明位置不对,被放到了第三行 | 挪到第一或第二行 |
UnicodeDecodeError ... can't decode byte | I/O 编码 | open()没写 encoding,用了系统默认 | 显式指定encoding="utf-8" |
UnicodeEncodeError ... can't encode | 输出编码 | 终端或目标编码装不下这些字符 | 改 stdout 编码或写文件 |
Unexpected UTF-8 BOM | 文件内容 | 文件带 BOM,用utf-8读了 | 改utf-8-sig或去 BOM |
| 能跑但中文全是问号或方块 | 输出编码 | 终端字体或代码页不匹配 | 换终端、改代码页、写文件验证 |
| 首个字段名多一个不可见字符 | 文件内容 | BOM 被当成内容 | 用二进制方式确认,改utf-8-sig |
5.2 五个高频踩坑场景复盘
场景一:声明写在了第三行。有人写脚本习惯先写 shebang,再写一段版权注释,然后才想起编码声明。第一行是注释,所以第二行会被检查,第二行是版权注释,没匹配到,到此为止——第三行连看都不看。表现就是"我明明写了声明,怎么还报错"。这类问题的排查成本极低(数一下行号),但发现成本很高,因为人总是相信自己写了。
场景二:声明写了 utf-8,文件实际是 GBK。这个组合最阴险的地方在于它不一定报错。GBK 编码的中文是两字节,其中不少组合恰好也是合法的 UTF-8 序列的情况下不会抛异常,解出来是一堆莫名其妙的字符。我遇到过一次接口返回的字段名全是乱码,查了两小时以为是服务端问题,最后发现是本地一个配置文件被某编辑器偷偷用 GBK 保存了。从那以后我养成了一个习惯:任何"内容诡异但程序不报错"的情况,先xxd看一眼原始字节。
场景三:容器里读文件报 ascii 解码失败。本地跑得好好的,CI 上必挂。根因是基础镜像没设LANG,locale.getpreferredencoding()返回 ASCII。修法不是去改镜像的 locale(虽然也能修),而是在代码里把所有open()的 encoding 补齐。前者治标,后者治本,而且后者在你换基础镜像时依然成立。
场景四:重定向到文件之后中文变乱码。控制台里print正常,python app.py > out.txt之后文件内容打不开或者乱码。原因是输出到终端时走的是控制台 API,输出到文件时走的是sys.stdout.encoding对应的编码,两者可能不是一回事。解决办法前面说过,别用print干数据输出的活。
场景五:# -*- coding: utf-8 -*-写了,open()读文件还是炸。这是最典型的一层混淆,我至少在三个不同的团队里见过有人反复在这上面打转。把第 1.3 节那张三层模型图贴在工位上,比什么教程都管用。
5.3 上线前的检查清单与团队约定
编码问题有个特点:它几乎不会在开发阶段暴露,专门挑上线、换机器、导数据的时候发作。所以靠人盯不如靠流程。我现在的项目里固定跑这么一份清单:
- 源码统一保存为UTF-8 无 BOM,在编辑器配置里固化,不靠手动选择
- 全仓库所有
open()/read_text()/write_text()必须带encoding,用静态检查卡住 - 仓库根目录放一份
.editorconfig,把charset = utf-8和end_of_line = lf写死 - CI 里跑一遍第 3.3 节的扫描脚本,出现非 UTF-8 源码直接失败
- 容器基础镜像显式设置
LANG=C.UTF-8,同时代码里该写的 encoding 一个不少 - 日志 handler 全部显式
encoding="utf-8",别指望平台默认值
至于那行声明本身,我的做法是:新项目里不强制写,但如果团队里还有 Python 2 的历史模块,或者代码需要发给外部合作方(对方的运行环境不可控),就统一写上。判断标准不是"技术上有必要吗",而是"这行字能不能省掉一次沟通"。工程上的很多约定都是这个逻辑——它不解决技术问题,它解决的是人的问题。
最后分享一个我用了很久的小习惯:任何一次编码相关的排查结束之后,把当时xxd出来的那几行字节、检测出的编码名、报错的完整堆栈,一起贴到问题记录里。下次再遇到同类问题,你会发现自己的历史记录比任何搜索引擎都好用,因为那是你自己环境里长出来的答案。