AI 技术博客
返回首页
Python 基础 21 分钟阅读

文件编码:encoding 参数与文本/二进制模式

Python编码UTF-8GBK文件

引言

前两篇的读写示例都悄悄做了一件事:把字符串变成了磁盘上的字节,再把字节变回字符串——只是我们没说破。这一层转换由 open() 的 encoding 参数暗中完成,而它正是跨平台项目里最阴险的 bug 来源:在你这台机器上运行好好的脚本,拷到中文 Windows 或换成 UTF-8 语言的服务器上,要么抛 UnicodeDecodeError,要么把「你好」读成乱码,程序还照常运行。

第五章第 5 篇(编码与 Unicode)已经建立了底层的两个模型:str 是码点序列,bytes 是字节序列;encode 是打包,decode 是拆包。本篇把这些概念落到文件这一层:encoding 参数如何决定打包方案、默认编码到底由谁决定(本机实测)、UTF-8 与 BOM 的爱恨纠葛、中文 Windows 的 GBK 场景怎么互转、二进制模式与文本模式的分界线,以及 errors 容错策略的代价。读完后,任何「文件乱码」类事故你都能按本篇的模型定位。前提:第 1 篇的 open 模式、第 2 篇的读写 API;路径操作留给第 4 篇,CSV/JSON 留给第 7 篇。所有代码在 Python 3.12 本机实测,输出逐字一致。

概念与原理

磁盘上只有字节:写是编码,读是解码

文件系统不认「字符」——磁盘上的每个文件都是一串字节。所谓文本文件,只是「字节流 + 一个约定的编码方案」的合体。因此写文件时,Python 必须把你的 str 编码成 bytes 才能落盘;读文件时,再把读到的 bytes 解码成 str。open() 的 encoding='utf-8' 就是告诉 Python 用哪套「打包方案」:f.write('中文') 内部等价于把 '中文'.encode('utf-8') 交给底层;f.read() 内部等价于把原始字节 .decode('utf-8')。两边用不同的方案,就会出现「写入是 utf-8、读取按 gbk 解」的错位事故。编码名写法很宽容:'utf-8'、'UTF8'、'utf_8' 指向同一个编解码器(codec,大小写与分隔符不敏感,由 codecs.lookup 归一化)。

默认编码:谁说了算

问「不写 encoding 用什么」之前,先分清三个不同的「默认」:

  • sys.getdefaultencoding():解释器内部字符串与源码的默认,Python 3 起恒为 'utf-8'(PEP 3120),与本篇无关但常被误引用。
  • locale.getpreferredencoding(False):按系统语言区域(locale)推荐的编码,这是 open() 不指定 encoding 时的依据。
  • sys.getfilesystemencoding():文件系统路径名的编码,Windows 上 Python 3.6 起为 'utf-8'。

所以「默认编码」是环境相关的:传统中文 Windows(语言区域 zh-CN)的推荐编码是 cp936,也就是 GBK;UTF-8 语言环境(Linux 常见、macOS 默认)推荐 utf-8。两个坑由此而来:第一,同一份代码在两种机器上读出不同的字节;第二,open() 默认值随环境漂移,程序行为的正确性取决于运行环境。业界共识是显式写 encoding='utf-8',让行为与环境解耦。这条路径也正在走向终点:PEP 686(让 UTF-8 模式成为默认)已被接受,Python 3.15 起不指定编码也将默认 UTF-8。

UTF-8 与 BOM:utf-8 与 utf-8-sig

UTF-8 是变长编码,互联网事实标准,也是本书推荐的文件编码。它有一个历史遗留物:BOM(Byte Order Mark,字节序标记)。BOM 本是 Unicode 用来标记「字节序」的字符 U+FEFF,UTF-8 没有字节序概念,但 Windows 记事本等工具习惯在 UTF-8 文件开头写 3 字节 EF BB BF 声明「本文件是 UTF-8」。问题在于:Python 按 'utf-8' 读取时不会剥掉它,于是文件第一个「字符」变成 \ufeff(不可见),字符串比较、startswith 全部静默失败。解决办法是 encoding='utf-8-sig':写入时自动加 BOM,读取时自动剥掉 BOM,与第三方工具互转时最稳。

GBK:中文 Windows 的旧世界

GBK 是简体中文 Windows 传统 ANSI 代码页(cp936)对应的编码,一个汉字固定 2 字节。九十年代到 2010 年代产出的中文数据(旧系统导出文件、老数据库备份、Windows 记事本默认保存的 txt)大量是 GBK 字节流。与现代系统互转的流程只有两步:用正确的编码读进来(得到 str),再用目标编码写出去(得到字节)——字符在内存里是编码无关的,转换发生在 str 与 bytes 的边界上。见下面实测。

文本模式与二进制模式的分界线

open(p)(文本模式)返回的 TextIOWrapper 内含一条解码管线:读时字节 → 解码 → str;写时 str → 编码 → 字节。open(p, 'rb')(二进制模式)则完全跳过这一层,直接给你 bytes。分界线的规则极其严格:文本模式 write 只收 str,二进制模式 write 只收 bytes,跨类型直接 TypeError。该用哪个?处理「人读的文本」(日志、配置、源代码)用文本模式;处理「机器读的字节流」(图片、二进制协议、带精确偏移的解析)用二进制模式——二进制模式还带来第 2 篇讲过的自由 seek。

errors 参数:解码失败的三种姿态

读取时遇到「本编码下无意义的字节序列」,默认(strict)抛 UnicodeDecodeError 终止程序;errors='replace' 把坏字节替换成替换符 U+FFFD(写编码失败时替换为 '?');errors='ignore' 直接丢弃坏字节。replace 与 ignore 让程序「活着」但也悄悄丢数据:坏字节没有被正确还原,只是换了一种方式消失。它们适合的场景是「宁可少读一两行也要继续」(日志采集、脏数据清洗),绝不适合需要逐字节保真的场景。

写入时的 UnicodeEncodeError:时机与残留

与读取对称,写入也有限制:目标编码放不下的字符会抛 UnicodeEncodeError。比如 ascii 编码只有 128 个码位,写「中文」必炸。实测发现两个值得记住的事实:第一,错误发生在 write() 调用内部(编码在写入缓冲时就执行,不等 close);第二,错误发生时文件已经以空文件形式创建在磁盘上——更糟的情况是数据量大到缓冲已部分刷盘时,错误之前的部分字节已经写进文件,文件处于「半成品」状态。因此「先试写、失败再删除」的防御要自己写,Python 不会替你回滚。

操作与实现

实测:三个默认编码与「中文」的字节形态

import sys, locale, tempfile, os

print('sys.getdefaultencoding():', sys.getdefaultencoding())
print('locale.getpreferredencoding(False):', locale.getpreferredencoding(False))
p = os.path.join(tempfile.gettempdir(), 'demo_enc_info.txt')
f = open(p, 'w')
print('不指定 encoding 时 open() 默认:', f.encoding)
f.close()
os.remove(p)
print('「中文」的 GBK 字节:', '中文'.encode('gbk'))
print('「中文」的 UTF-8 字节:', '中文'.encode('utf-8'))
sys.getdefaultencoding(): utf-8
locale.getpreferredencoding(False): utf-8
不指定 encoding 时 open() 默认: utf-8
「中文」的 GBK 字节: b'\xd6\xd0\xce\xc4'
「中文」的 UTF-8 字节: b'\xe4\xb8\xad\xe6\x96\x87'

本机(UTF-8 语言区域)实测:open() 默认 utf-8。同一句「中文」在 GBK 下是 4 字节、UTF-8 下是 6 字节——字节完全不同,这就是跨编码读文件的错位根源。在语言区域为 zh-CN 的传统 Windows 上,locale.getpreferredencoding(False) 返回 cp936,open() 默认即 GBK,脚本在两台机器上行为不一致的剧情由此上演。

UTF-8 读写往返:一进一出,字节对齐

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_utf8.txt')
with open(p, 'w', encoding='utf-8') as f:
    print('写入时句柄编码:', f.encoding)
    f.write('Python 文件编码实战')
with open(p, encoding='utf-8') as f:
    print('读出内容:', repr(f.read()))
print('文件字节数:', os.path.getsize(p))
os.remove(p)
写入时句柄编码: utf-8
读出内容: 'Python 文件编码实战'
文件字节数: 25

f.encoding 报告句柄实际使用的编码;「Python 文件编码实战」13 个字符在 UTF-8 下占 25 字节(7 个 ASCII 各 1 字节 + 6 个汉字各 3 字节),读写双方都用 encoding='utf-8',往返无损。

UTF-8 与 BOM:utf-8-sig 的正确用法

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_sig.txt')
with open(p, 'w', encoding='utf-8-sig') as f:
    f.write('带 BOM 的文本')
with open(p, 'rb') as f:
    raw = f.read()
print('文件开头 3 字节:', raw[:3])
print('BOM 标准字节:', b'\xef\xbb\xbf')
print('UTF-8 编码不含 BOM 时开头:', '带 BOM 的文本'.encode('utf-8')[:3])
with open(p, encoding='utf-8-sig') as f:
    print('utf-8-sig 读出:', f.read())
with open(p, encoding='utf-8') as f:
    print('utf-8 直接读(带 BOM 文件):', repr(f.read()))
os.remove(p)
文件开头 3 字节: b'\xef\xbb\xbf'
BOM 标准字节: b'\xef\xbb\xbf'
UTF-8 编码不含 BOM 时开头: b'\xe5\xb8\xa6'
utf-8-sig 读出: 带 BOM 的文本
utf-8 直接读(带 BOM 文件): '\ufeff带 BOM 的文本'

utf-8-sig 写入时文件头多了 BOM 三字节;用 utf-8-sig 读回时 BOM 被剥掉,内容干净;而用普通 utf-8 去读,内容开头多出不可见的 \ufeff——字符串匹配、CSV 表头比较就会莫名失败。凡是「文件可能来自 Windows 记事本/Excel」的场景,读入一律用 utf-8-sig 最稳妥。

GBK 文件与 UTF-8 互转

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_gbk.txt')
with open(p, 'w', encoding='gbk') as f:
    f.write('中文')
with open(p, 'rb') as f:
    raw = f.read()
print('GBK 写入的字节:', raw)
with open(p, encoding='gbk') as f:
    print('GBK 读出:', f.read())
try:
    with open(p, encoding='utf-8') as f:
        f.read()
except UnicodeDecodeError as e:
    print('用 utf-8 读 GBK 文件:', type(e).__name__, '错误位置 start =', e.start)
os.remove(p)
GBK 写入的字节: b'\xd6\xd0\xce\xc4'
GBK 读出: 中文
用 utf-8 读 GBK 文件: UnicodeDecodeError 错误位置 start = 0

GBK 写出的 4 字节,用 GBK 读回无损;用 UTF-8 去读则第一个字节就不合法,当场 UnicodeDecodeError。互转的正确姿势:with open(gbk_path, encoding='gbk') as f: text = f.read(),再 with open(utf8_path, 'w', encoding='utf-8') as f: f.write(text)——转换发生在内存里的 str 上,与两边编码无关。

文本与二进制模式:str/bytes 分界线

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_strbytes.txt')
with open(p, 'w', encoding='utf-8') as f:
    f.write('你好')
with open(p, encoding='utf-8') as f:
    text = f.read()
with open(p, 'rb') as f:
    data = f.read()
print('文本模式读出:', type(text).__name__, repr(text))
print('二进制模式读出:', type(data).__name__, data)
try:
    with open(p, 'w', encoding='utf-8') as f:
        f.write(b'bytes')           # 文本模式只接受 str
except TypeError as e:
    print('文本模式写 bytes ->', type(e).__name__, '-', e)
try:
    with open(p, 'wb') as f:
        f.write('字符串')            # 二进制模式只接受 bytes
except TypeError as e:
    print('二进制模式写 str ->', type(e).__name__, '-', e)
os.remove(p)
文本模式读出: str '你好'
二进制模式读出: bytes b'\xe4\xbd\xa0\xe5\xa5\xbd'
文本模式写 bytes -> TypeError - write() argument must be str, not bytes
二进制模式写 str -> TypeError - a bytes-like object is required, not 'str'

同一份文件,文本模式读出 str「你好」,二进制模式读出 bytes 的 6 个 UTF-8 字节。类型错配时双方互不迁就:文本 write 只收 str,二进制 write 只收 bytes,都以 TypeError 当场拒绝——这个「铁面无私」是好事,避免了解码层被悄悄跳过。

errors 容错:replace 与 ignore 的代价

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_errors.txt')
with open(p, 'wb') as f:
    f.write('中文'.encode('gbk'))
with open(p, encoding='utf-8', errors='replace') as f:
    print('errors=replace 读出:', f.read())
with open(p, encoding='utf-8', errors='ignore') as f:
    print('errors=ignore 读出:', repr(f.read()))
os.remove(p)
errors=replace 读出: ����
errors=ignore 读出: ''

4 个 GBK 字节在 UTF-8 下全部非法:replace 把每个坏字节换成替换符 U+FFFD(显示为 �),ignore 则全部丢弃、读出一个空串。注意:两种策略都没有修复数据,只是决定「怎么难看地活下去」——采集外部脏数据时可接受,保真场景绝不能用。

UnicodeEncodeError:写入时当场抛出

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_encode_err.txt')
try:
    with open(p, 'w', encoding='ascii') as f:
        print('write() 调用前')
        f.write('中文')
        print('write() 调用后(未到达)')
except UnicodeEncodeError as e:
    print(type(e).__name__, ':', e)
    print('文件残留在磁盘:', os.path.exists(p), '| 残留大小:', os.path.getsize(p))
os.remove(p)
write() 调用前
UnicodeEncodeError : 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)
文件残留在磁盘: True | 残留大小: 0

错误在 write() 内部编码阶段立即抛出,「调用后」一行从未执行;且磁盘上已经留下一个 0 字节的空文件——文件创建先于内容写入。消息里的「position 0-1:ordinal not in range(128)」指第 0、1 两个字符(「中」「文」)的码位超出 ascii 的 0-127 范围。防御要点:要么用能容纳内容的编码,要么对「可丢弃」的数据显式 errors='replace',并意识到错误前已刷盘的部分不会回滚。

易错点与陷阱

陷阱一:不指定 encoding,行为随运行环境漂移

同一脚本:在你的 UTF-8 机器上正常,部署到中文 Windows 上 open() 默认变成 cp936,读 UTF-8 文件抛 UnicodeDecodeError、写文件生成 GBK 字节流,下游全部错乱。规则:所有文件读写一律显式 encoding='utf-8'(除非确实处理 GBK 数据),把正确性焊死在代码里,不要交给环境。PEP 686 落地前(Python 3.15),这是跨平台铁律。

陷阱二:带 BOM 的文件用 utf-8 读

记事本/Excel 保存的 UTF-8 文件常带 BOM 头。用 'utf-8' 读取时,文件内容第一个字符是看不见的 \ufeff:「表头是 id」的判断、line == 'id,name' 的比较全部静默失败。遇到「明明内容一样却对不上」的诡异问题,先查开头是不是 \ufeff;读第三方文本一律 encoding='utf-8-sig'。

陷阱三:errors=‘ignore’ 静默吞掉数据

ignore 模式下的解码失败没有任何痕迹——你拿到的是一份「自动删减版」数据,行号错位、字段缺失,却没有任何异常可抓。replace 至少留了替换符让你察觉「这里原本有字节」。生产数据解析里用 ignore 前要问:我真的可以接受丢字节吗?

陷阱四:文本模式与二进制模式混用

f.write(b'...') 在文本句柄上 TypeError、f.write('...') 在二进制句柄上 TypeError,这类错误好认。更隐蔽的是「读文本用 rb」:拿到 bytes 后与 str 比较永远 False、正则配不上、json.load 直接报错——第 7 篇会看到 json 对 str 与 bytes 的分辨。模式选择要与数据性质一致,别为省事混搭。

小结

文件层面的一切都归结为一句话:磁盘存字节,encoding 决定打包方案。open() 的默认编码来自语言区域(本机实测 UTF-8 区域为 utf-8;中文 Windows 传统上是 cp936/GBK),所以显式写 encoding 是跨平台铁律;UTF-8 读写用 'utf-8'、与 Windows 工具互转用 'utf-8-sig'、处理旧数据用 'gbk',互转发生在 str 层;文本模式与二进制模式以 str/bytes 类型为界,互不迁就;解码失败默认抛错,replace/ignore 是「带伤存活」的代价;写入编码失败在 write() 内当场抛 UnicodeEncodeError,且不负责回滚。乱码事故从此不再是玄学:先确认文件真实编码,再对齐读写两侧,即可定位。下一站第 4 篇:pathlib 路径操作。

练习与思考题

  1. 把任意文本文件按「读 'gbk' → 写 'utf-8-sig'」转换,再反向转换一次,验证两次转换后内容不变。思考:为什么转换后的字节数会变?
  2. 用 'utf-8' 写入「你好世界」,再用 'gbk' 读回。观察结果并解释:为什么有的字节会被替换成 \ufffd 或乱码,而报错与否取决于字节是否恰好「撞上」合法序列?
  3. 解释以下事故并给出修复:data = open('a.txt').read() 在 Linux 上正常,在中文 Windows 上抛 UnicodeDecodeError。
  4. 写出「带 BOM 的 UTF-8 文件用 utf-8-sig 读取时 BOM 被剥离」的验证代码(用 startswith 或开头切片证明 \ufeff 不在了)。
  5. 思考题:errors='replace' 为什么在写入方向用 '?' 而不是 U+FFFD?提示:目标是兼容尽可能多的下游系统。