Python CSV 编码排障:识别 BOM,严格转换 GB18030 和 UTF-16
CSV 在另一台机器上能打开,放进 Linux 上的 Python 脚本却报 UnicodeDecodeError;或者第一列明明叫 sample_id,程序读到的却是 \ufeffsample_id。先把字节编码处理正确,再做CSV 流式统计,能把这两类问题分开排查。
这篇教程用 Python 标准库检查 BOM,把明确来源的 GB18030、带 BOM 的 UTF-16 或 UTF-8 文件转成不带 BOM 的 UTF-8。示例面向初学者到中级读者,只转换编码,不改变字段、引号、分隔符或换行。
1. 方法:先确定来源编码
按下面的顺序处理:
- 查看导出工具的编码设置,保留原始文件。
- 查看文件开头的字节,有 BOM 时据此选择解码方式。
- 没有 BOM 时,向数据提供方确认编码;不要轮流试编码,把“不报错”当成正确。
| 开头字节 | 本文使用的参数 |
|---|---|
ef bb bf |
--from-encoding utf-8-sig |
ff fe 或 fe ff,且不是 UTF-32 BOM |
--from-encoding utf-16 |
| 没有 BOM,来源明确为 GB18030 | --from-encoding gb18030 |
| 没有 BOM,来源明确为 UTF-8 | --from-encoding utf-8-sig |
utf-8-sig 解码会跳过开头可选的 UTF-8 BOM。没有 BOM 不代表文件就是 GB18030;解码成功也不能证明文本含义正确。不要用 errors="ignore" 或 errors="replace" 绕过问题,它们可能丢弃字节或替换字符,见 Python 编码错误处理文档。
2. 准备:安装条件与演示文件
需要 Python 3.11 或更新版本,不需要 pip 包。本文示例在 Linux、Python 3.11.4 上验证。脚本只接受不超过 20 MiB 的输入,会在内存中保留输入字节、解码文本和输出字节;它不是大型文件流式转码器。
在一个新的可写目录中操作。下面的命令使用独占创建,若文件已经存在会报错,不会覆盖:
python3 - <<'PYTHON'
from pathlib import Path
text = 'sample_id,note\r\n001,中文\r\n002,"comma,inside"\r\n003,"line1\r\nline2"\r\n'
for name, encoding in (
("source-gb.csv", "gb18030"),
("source-bom.csv", "utf-8-sig"),
("source-utf16.csv", "utf-16"),
):
with Path(name).open("xb") as handle:
handle.write(text.encode(encoding))
PYTHON
查看前四个字节:
python3 - <<'PYTHON'
from pathlib import Path
for name in ("source-gb.csv", "source-bom.csv", "source-utf16.csv"):
with Path(name).open("rb") as handle:
prefix = handle.read(4)
print(name, prefix.hex(" "))
PYTHON
GB18030 示例以 ASCII 表头 73 61 6d 70 开始,UTF-8 BOM 示例以 ef bb bf 73 开始。UTF-16 示例的 BOM 可能是 ff fe 或 fe ff,取决于生成它的平台;不要因此手工删除两个字节后按 UTF-8 读取。
3. 实现:保存完整脚本
把下面的代码保存为 convert_csv_encoding.py:
"""Convert a small text/CSV file to UTF-8 without overwriting any file."""
import argparse
import codecs
from pathlib import Path
import sys
MAX_BYTES = 20 * 1024 * 1024
def bom_name(data):
# UTF-32 must precede UTF-16: their prefixes can overlap.
for mark, name in (
(codecs.BOM_UTF32_LE, "utf-32-le"),
(codecs.BOM_UTF32_BE, "utf-32-be"),
(codecs.BOM_UTF8, "utf-8-sig"),
(codecs.BOM_UTF16_LE, "utf-16-le"),
(codecs.BOM_UTF16_BE, "utf-16-be"),
):
if data.startswith(mark):
return name
return None
def convert(source, destination, encoding):
with source.open("rb") as handle:
data = handle.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise ValueError("input exceeds the 20 MiB limit")
bom = bom_name(data)
allowed = {
"utf-8-sig": {None, "utf-8-sig"},
"gb18030": {None},
"utf-16": {"utf-16-le", "utf-16-be"},
}
if bom not in allowed[encoding]:
raise ValueError(f"BOM {bom!r} conflicts with {encoding}")
text = data.decode(encoding, errors="strict")
result = text.encode("utf-8", errors="strict")
# Input validation finishes before exclusive output creation.
with destination.open("xb") as handle:
handle.write(result)
return len(data), len(result)
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--from-encoding", required=True,
choices=("utf-8-sig", "gb18030", "utf-16"))
args = parser.parse_args()
try:
before, after = convert(args.input, args.output, args.from_encoding)
except (OSError, UnicodeError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"OK: input_bytes={before} output_bytes={after}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
脚本先读取有限的字节数,检查 BOM 与所选编码是否冲突,然后严格解码并重新编码。UTF-32 BOM 会先于 UTF-16 检查,避免把 ff fe 00 00 错当成 UTF-16;UTF-32 不在本例支持范围内。UTF-16 输入必须带 BOM,未带 BOM 的文件请先确认字节序,再从来源工具重新导出。
输出使用 open() 的 x 模式,所以已有文件、同名输入路径及已有符号链接都不会被覆盖。输入解码失败时,输出尚未创建。写入遇到磁盘错误或进程被中断时,可能留下部分输出;这不是原子提交,只有成功退出的结果才能进入后续处理。
先查看用法,再执行:
python3 --version
python3 convert_csv_encoding.py --help
python3 convert_csv_encoding.py source-gb.csv clean-gb.csv --from-encoding gb18030
python3 convert_csv_encoding.py source-bom.csv clean-bom.csv --from-encoding utf-8-sig
python3 convert_csv_encoding.py source-utf16.csv clean-utf16.csv --from-encoding utf-16
成功时输出 OK: input_bytes=... output_bytes=... 并返回 0。输入、编码或文件操作错误返回 1,参数错误由 argparse 返回 2。转换过程直接处理完整文本,不重新解析或写出 CSV,因此 CRLF、带引号的逗号、字段内换行和 001 的前导零都会保留;它也不会检查 CSV 的业务格式。
4. 验证:检查内容,不只看退出码
对三个结果执行同一个检查:
python3 - <<'PYTHON'
import codecs
import csv
from pathlib import Path
expected = [
["sample_id", "note"],
["001", "中文"],
["002", "comma,inside"],
["003", "line1\r\nline2"],
]
outputs = [Path(name).read_bytes() for name in
("clean-gb.csv", "clean-bom.csv", "clean-utf16.csv")]
assert outputs[0] == outputs[1] == outputs[2]
assert not outputs[0].startswith(codecs.BOM_UTF8)
for name in ("clean-gb.csv", "clean-bom.csv", "clean-utf16.csv"):
with open(name, encoding="utf-8", newline="") as handle:
assert list(csv.reader(handle, strict=True)) == expected
print("PASS: identical UTF-8 bytes; IDs, quotes and embedded CRLF preserved")
PYTHON
预期输出:
PASS: identical UTF-8 bytes; IDs, quotes and embedded CRLF preserved
CSV 验证时使用 newline="",让解析器处理换行,具体说明见 Python CSV 文档。真实数据还要核对中文、表头、记录数和关键 ID;编码已正确但原文早已是乱码时,转码不会恢复丢失的信息,需要重新导出。
下面检查错误路径。第一条故意用 UTF-8 解码 GB18030,第二条尝试覆盖已有输出:
python3 convert_csv_encoding.py source-gb.csv wrong.csv --from-encoding utf-8-sig
printf 'exit=%s\n' "$?"
python3 -c 'from pathlib import Path; assert not Path("wrong.csv").exists()'
python3 convert_csv_encoding.py source-bom.csv clean-bom.csv --from-encoding utf-8-sig
printf 'exit=%s\n' "$?"
两次都应显示 ERROR: ... 和 exit=1;wrong.csv 不应存在,已有的 clean-bom.csv 应保持原样。然后重新执行本节的内容验证,确认覆盖尝试没有改变文件。
5. 接入自动化与常见问题
只在转换成功后启动下一步。换用新的输出文件名,或在每轮任务的独立工作目录中运行:
python3 convert_csv_encoding.py source-gb.csv ready.csv --from-encoding gb18030 &&
python3 -c 'import csv; f=open("ready.csv", encoding="utf-8", newline=""); print(next(csv.reader(f))); f.close()'
预期表头为 ['sample_id', 'note']。后续可以做重复 ID 检查;编码转换不能代替唯一性检查。
| 现象 | 具体处理 |
|---|---|
UTF-8 UnicodeDecodeError |
核对导出编码;仅在来源确认后使用 gb18030 或重新导出 UTF-8。 |
第一列包含 \ufeff |
对 UTF-8 来源使用 utf-8-sig,再检查首列表头。 |
BOM ... conflicts with ... |
核对前四个字节和来源,选择对应参数;UTF-32 需另行处理。 |
File exists |
选择新的输出名,避免用输入路径、已存在文件或符号链接作为输出。 |
| 超过 20 MiB | 改用带增量解码和失败提交控制的流式方案;不要简单取消限制后处理超大文件。 |
Permission denied 或磁盘写入失败 |
改用可写目录并检查可用空间;失败任务的部分输出不可当作成功结果。 |
| 转换成功却仍有乱码 | 检查来源编码是否选错,或原始导出内容是否已经损坏。 |
保留原始文件及来源编码记录。需要追踪文件变化时,可使用SHA-256 清单分别记录原始和转换后的文件;编码不同通常会导致校验值不同,这不等于字段内容发生变化。
总结
先确认来源编码,再用严格解码转换为 UTF-8,避免用忽略错误的方式掩盖内容丢失。保留原始文件,用新路径接收结果,并同时检查退出码和实际 CSV 内容。编码处理通过后,再进入统计、去重或其他自动化步骤。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-csv-encoding-utf8.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。