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