🏠 总目录📚 本教程 10 · 编码、路径和文件 ← →
📑 本页目录(点开跳转)

10 · 编码、路径和文件

⏱ 40 分钟 | ⭐ 「在我机器上好好的」十次里有八次是这一章的内容


🎯 一句话

open() 不写 encoding= 时用的是「平台默认」,而这个默认在 Windows 上是 GBK、在 Linux/Mac 上是 UTF-8 —— 同一份代码换台机器就炸。

这一章讲的全是跨机器才暴露的坑:编码、BOM、换行符、路径。它们在你自己电脑上一次都不会出现,一交给别人就全来了。


🧩 一、open() 的默认编码是个陷阱

先看本机(Windows 11,Python 3.13)真实的三个值:

import locale, sys

print(locale.getpreferredencoding(False))   # cp936     ⭐ 就是 GBK
print(sys.getdefaultencoding())             # utf-8     ⚠️ 这个管的不是文件
print(sys.stdout.encoding)                  # utf-8

⚠️ 三个值互不相同,而 open() 用的是第一个 —— cp936。 sys.getdefaultencoding() 管的是 str 和 bytes 互转,跟读文件没关系,别被名字骗了。

于是这段代码在 Windows 上必炸:

import tempfile, os

d = tempfile.mkdtemp()
p = os.path.join(d, "zh.txt")

with open(p, "w", encoding="utf-8") as f:      # 写的时候好好的
    f.write("你好,世界")

with open(p) as f:                             # ⚠️ 读的时候没写 encoding
    print(f.read())
# UnicodeDecodeError: 'gbk' codec can't decode byte 0x8c in position 14:
#   incomplete multibyte sequence

⭐ 注意报错里出现的是 'gbk' codec —— 你从头到尾没提过 GBK,是 open() 替你选的。

⭐ 一条规矩,没有例外: 凡是 open() 处理文本,一律显式写 encoding="utf-8"。 不是「最好写」,是「不写就是 bug,只是还没被触发」。

⚠️ Python 3.15 起默认会改成 UTF-8(PEP 686),但你写的代码要在 3.9~3.14 上跑很多年。 想提前统一,可以给命令加 -X utf8 或设 PYTHONUTF8=1,但别指望别人的环境有这个。


🧩 二、BOM:多出来的那个看不见的字符

有些工具(Excel 导出的 CSV 是重灾区)会在文件开头塞三个字节 EF BB BF,叫 BOM。

import tempfile, os

d = tempfile.mkdtemp()
p = os.path.join(d, "bom.txt")
with open(p, "w", encoding="utf-8-sig") as f:      # 写入带 BOM
    f.write("id,name")

a = open(p, encoding="utf-8").read()
b = open(p, encoding="utf-8-sig").read()

print(repr(a))        # '\ufeffid,name'    ⚠️ 多了一个字符
print(repr(b))        # 'id,name'
print(a == b)         # False

💀 它的杀伤力在于「看起来完全正常」:你 print(a) 出来就是 id,name, 但 a.split(",")[0] == "id" 是 False —— 真实的第一列名叫 '\ufeffid'。

于是典型症状是:df["id"] 报 KeyError,而你把列名打印出来看着一模一样。

⭐ 解法:读可能带 BOM 的文件用 encoding="utf-8-sig"(没有 BOM 时它行为和 utf-8 一样, 所以可以无脑用);写给 Excel 看的 CSV 主动用 utf-8-sig,否则 Excel 打开中文是乱码。


⚠️ 这一节是本项目自己的事故,写在这里是因为它太典型了。

Windows 控制台的默认编码不是 UTF-8。所以:

print("✅ 全部链接有效")
# UnicodeEncodeError: 'gbk' codec can't encode character '\u2705' ...

💀 本项目的真实事故:构建脚本的最后一步是往页面里注入 SVG 图。 它跑在「旧图已被清掉、新图还没注进去」的中间态上。 那一步因为打印一个 ✅ 崩了,而外层 except 打印的是 「图例注入失败(不影响其他内容)」—— 在站点已经坏掉的时候报了平安。 全站 156 张图当场只剩 75 张。

⚠️ 更阴的一次:一个校验脚本检查全部通过之后,崩在打印 ✅ 全部链接有效 这一行上。 而它的失败信息里也有 emoji —— 所以「真有坏链」时同样崩。 你看到一个 traceback,却分不清是脚本坏了还是站点坏了。

⭐ 对策是每个脚本开头钉死输出编码:

import io, sys

sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")

⭐ errors="replace" 那半句同样重要:万一还有编码不了的字符,打个 ? 也别抛异常 —— 你不希望一个日志行把主流程干掉。


🛑 读到这里可以停 —— 前半章讲完了(约 18 分钟)。 你已经知道 open() 的默认编码是个陷阱、BOM 会制造「看起来一样的不同字符串」、控制台编码能崩掉整个流程。 后半章还有:换行符与 CSV 的双回车 · pathlib · Windows 路径的三个坑 · 相对路径到底相对于谁。 回来的时候不用重读,直接从下一节接着看。


🧩 四、换行符:CSV 的双回车

Windows 的行尾是 \r\n,Unix 是 \n。Python 的文本模式会替你转换 —— 而这正是问题所在。

import tempfile, os, csv

d = tempfile.mkdtemp()
for nl in ("", None):
    p = os.path.join(d, f"c{nl!r}.csv")
    with open(p, "w", newline=nl, encoding="utf-8") as f:
        csv.writer(f).writerow(["a", "b"])
    print(f"newline={nl!r:6} → {open(p, 'rb').read()!r}")

# newline=''     → b'a,b\r\n'
# newline=None   → b'a,b\r\r\n'     ⚠️ 两个 \r

⭐ csv 模块自己会写 \r\n,而 newline=None(默认)又把那个 \n 翻译成 \r\n —— 于是变成 \r\r\n。用 Excel 打开会看到每行之间多一个空行。

⭐ 规矩:csv 读写一律加 newline=""。 这个参数的意思是「别动换行符,我自己管」,不是「用空字符串当换行符」。


🧩 五、pathlib:别再用字符串拼路径

import pathlib

p = pathlib.Path("/data") / "raw" / "x.csv"     # ⭐ / 是重载过的
print(p)                                        # 平台自适应的分隔符

q = pathlib.Path("a/b/c.tar.gz")
print(q.name)      # c.tar.gz
print(q.stem)      # c.tar     ⚠️ 只脱一层
print(q.suffix)    # .gz       ⚠️ 只取最后一个
print(q.suffixes)  # ['.tar', '.gz']

⚠️ .stem / .suffix 只处理最后一个点。c.tar.gz 的 stem 是 c.tar 不是 c。 要脱干净得自己循环,或者用 q.name.split(".")[0]。

常用的一把抓:

想干什么 写法
建目录(含父级、已存在不报错) p.mkdir(parents=True, exist_ok=True)
读写文本 p.read_text(encoding="utf-8") · p.write_text(s, encoding="utf-8")
读写二进制 p.read_bytes() · p.write_bytes(b)
遍历 p.glob("*.csv") · p.rglob("*.csv")(递归)
判断 p.exists() · p.is_file() · p.is_dir()
换后缀 / 换文件名 p.with_suffix(".parquet") · p.with_name("new.csv")

⚠️ 注意 read_text / write_text 也要写 encoding=,它们和 open() 一样默认跟平台。


🧩 六、Windows 路径的三个坑

① 反斜杠是转义符。

path = "C:\data\new\test.txt"
print(repr(path))     # 'C:\\data\new\test.txt'  ⚠️ \n 和 \t 已经变成换行和制表符了
# ⚠️ 3.12 起解释器还会先打一行 SyntaxWarning: invalid escape sequence —— 别忽略它,那就是在报这个坑

⭐ 用原始字符串 r"C:\data\new",或者干脆用正斜杠 "C:/data/new" —— Windows 的 API 两种都收。 最好的办法还是 pathlib,它根本不需要你手写分隔符。

② 大小写不敏感。

import tempfile, os

d = tempfile.mkdtemp()
open(os.path.join(d, "Abc.txt"), "w").close()
print(os.path.exists(os.path.join(d, "abc.txt")))   # True   ⚠️ 在 Linux 上是 False

💀 于是「本地跑得好好的,一上 Linux 服务器就 FileNotFoundError」。 ⭐ 文件名一律小写是最省事的规避。

③ 长路径限制。 传统上限 260 字符。现代 Windows 可以解除,但别指望。 深层嵌套的输出目录容易踩到,症状是莫名其妙的 FileNotFoundError 而路径明明是对的。

⚠️ 关于「保留文件名」(CON / PRN / AUX / NUL / COM1…): 老资料说这些名字连带后缀都建不出来。本机(Windows 11 + Python 3.13)实测 con.txt 是能建出来的 —— 行为随系统版本变。别把它当成可靠的规则记,但也别去踩。


🧩 七、⭐ 相对路径相对于谁

import os, pathlib

print(os.getcwd())                                   # 当前【工作目录】
print(pathlib.Path(__file__).resolve().parent)       # 脚本【自己】在哪

⚠️ open("data.txt") 找的是工作目录,不是脚本所在目录。 同一个脚本,cd 到项目根 跑和 cd 到脚本目录 跑,读到的是两个不同的文件(或者一个报错)。

⭐ 要读「和脚本放在一起」的文件,永远这么写:

import pathlib

HERE = pathlib.Path(__file__).resolve().parent
data = (HERE / "data.txt").read_text(encoding="utf-8")

💀 本项目在这上面栽过一次,而且极贵:构建脚本里写着

# 🧩 骨架:`os` 来自你自己的代码,这一段只看写法
ROOT    = r"C:\desktop\claude"                        # 硬编码,指向【老位置】
SCRATCH = os.path.dirname(os.path.abspath(__file__))  # 跟着脚本走,指向【新位置】

项目搬进新仓库后,构建读的是新目录的源,却把产物写进了老目录。

⚠️ 为什么一直没发现:构建不报错、还照常打印「一切正常」的收尾行; 全套校验脚本全绿,因为它们的 ROOT 也硬编码成老目录、查的是同一份老产物。 唯一的破绽是产物的修改时间没变,而没人会去看。 实况是:改完一整轮内容、构建、跑完 8 个校验脚本全过,回头一扫才发现 107 个文件还是旧的。

⭐ 现在全项目一律写成:

import os

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))   # 「本脚本上一级」

🔗 这一章连到哪里

相关的地方 为什么
08 · 环境、依赖和 import 同样是「在我机器上好好的」,那一章管装什么,这一章管读什么
09 · 容器、哈希与顺序 另一个跨进程才暴露的坑:PYTHONHASHSEED 让 set 的遍历顺序每次都不同
数据这一关 · 04 · 脏数据的十种形态 那一章 owns 时区(全站讲得最密的一处)。⭐ 编码和时区是「数据看着对、其实错了」的两大源头
AI 全栈 · 07b · 文件上传与对象存储 文件从用户手里到你磁盘上的那一段,编码和文件名的坑在那边还要再遇到一次
SQL 查询这一关 · 附录A 从 CSV 灌进数据库时,BOM 会让第一列名带上 \ufeff

✅ 检查点

  1. locale.getpreferredencoding(False) 和 sys.getdefaultencoding() 各管什么?open() 用哪个?
  2. 在 Windows 上用默认编码读一个 UTF-8 的中文文件,报错原文里出现的是哪个 codec?
  3. 带 BOM 的文件用 utf-8 读出来,a.split(",")[0] 为什么不等于 "id"?怎么修?
  4. csv.writer 写文件时不加 newline="",字节层面会变成什么?为什么?
  5. 为什么本项目要求每个脚本开头钉 sys.stdout = io.TextIOWrapper(...)?errors="replace" 有什么用?
  6. pathlib.Path("c.tar.gz").stem 是什么?
  7. open("data.txt") 找的是哪个目录?要读「和脚本放一起」的文件该怎么写?
  8. 「硬编码 ROOT」那次事故,为什么 8 个校验脚本全绿却还是错的?
👀 答案
  1. locale.getpreferredencoding(False) 是平台默认的文件编码(本机 cp936),open() 用的就是它;sys.getdefaultencoding()(utf-8)管的是 str/bytes 互转,和读文件无关,别被名字骗了。
  2. 'gbk' codec —— UnicodeDecodeError: 'gbk' codec can't decode byte 0x8c in position 14: incomplete multibyte sequence。你从头到尾没提过 GBK,是 open() 替你选的。
  3. 因为读出来是 '\ufeffid,name',第一列名实际是 '\ufeffid' —— 而 print 出来看着一模一样。修法:读的时候用 encoding="utf-8-sig"(没 BOM 时行为和 utf-8 一致,可以无脑用)。
  4. 变成 b'a,b\r\r\n'(两个 \r)。因为 csv 模块自己写 \r\n,而 newline=None 又把那个 \n 翻成 \r\n。Excel 打开会每行之间多一个空行。一律加 newline="",它的意思是「别动换行符,我自己管」。
  5. 因为 Windows 控制台默认不是 UTF-8,print("✅") 会抛 UnicodeEncodeError。本项目真实事故:构建最后一步因此崩在中间态上,而 except 打印的是「不影响其他内容」,在站点已经坏掉时报了平安,156 张图只剩 75 张。errors="replace" 保证万一还有编不了的字符就打个 ?,不让一行日志干掉主流程。
  6. c.tar,不是 c。.stem 和 .suffix 都只处理最后一个点;要完整的用 .suffixes(['.tar', '.gz'])。
  7. 找的是当前工作目录(os.getcwd()),不是脚本所在目录。正确写法:HERE = pathlib.Path(__file__).resolve().parent,然后 (HERE / "data.txt")。
  8. 因为校验脚本的 ROOT 也硬编码成了老目录 —— 构建把产物写进老目录,校验脚本也去老目录查,两边对上了所以全绿。唯一破绽是产物 mtime 没变。现在一律用 os.path.dirname(os.path.dirname(os.path.abspath(__file__)))。

🛑 可以停在这里

⚡ 走神救援

⭐ 这一章的坑有个共同点:在你自己机器上一次都不会出现,一交给别人就全来了。

头号问题是 open() 不写 encoding= 时用平台默认——本机默认就是 GBK,所以读一个 UTF-8 中文文件直接抛解码错。⭐ 报错里的那个编码是 open() 替你选的,你从没提过。 ⚠️ 别被 sys.getdefaultencoding() 那个 utf-8 骗了,它管的是字符串和字节互转、和文件无关。⭐ 规矩没有例外:凡是文本 open(),一律显式写 encoding="utf-8"。

第二个坑是 BOM:读出来的第一列名字前面多了一个看不见的字符,⭐ 打印出来完全正常,取列却报 KeyError 而列名看着一模一样。读用 utf-8-sig(没 BOM 时行为一致,可以无脑用),写给 Excel 也用它。

💀 第三个坑是控制台编码,本项目真栽过:构建最后一步因为打印一个 emoji 抛编码错,崩在「旧图已删、新图未注入」的中间态上,而 except 打印的是「不影响其他内容」——⭐ 在站点已经坏掉时报平安,全站图当场只剩一半。对策是每个脚本开头钉死 utf-8 输出。

换行符上,csv 不加 newline="" 会写出两个回车,Excel 里每行之间多一个空行。路径上:反斜杠是转义符、Windows 大小写不敏感(本地能跑、Linux 上找不到文件)、.stem 只脱一层。

⭐⭐ 最后一条最贵:相对路径相对于工作目录,不是脚本目录。 本项目硬编码根目录那次,构建读的是新目录的源,却把产物写进了老目录——💀 而八个校验脚本全绿,因为它们的根目录也硬编码成老的、查的是同一份老产物,一百多个文件的改动一个字都没进站点。⭐ 现在一律从 __file__ 推出根目录。

下一节 👉 附录A · 报错反查与速查

打卡记录保存在你的浏览器里,首页能看到总进度