UTF-8 BOM:修复 JSON 解析报错与 CSV 乱码
UTF-8 BOM 引发的 JSON 解析报错,起因是三个你看不见的字节。文件在编辑器里干干净净,cat 打印出来和预期一模一样,linter 也没有意见,可 JSON.parse 偏偏在第一个字符上就抛异常。
在 node v25.8.2 上实测,抛出来是这样:
SyntaxError: Unexpected token '', "{"a":1}" is not valid JSON
无论你的终端在那对引号里画出了什么,它都只是一个字符:U+FEFF,以字节 EF BB BF 存储。严格的 JSON 语法里没有它的位置。解析器在位置 0 上期待的是 {、[、数字、引号或空白字符,而 U+FEFF 一个都不是。
如果你已经确定它就是 BOM,从你能改动的那一侧下手:
| 你能改动的位置 | 对应做法 |
|---|---|
| Node,读取文件 | JSON.parse(raw.replace(/^/, '')) |
| Python,读取文件 | open(path, encoding='utf-8-sig') |
| 磁盘上的文件本身 | tail -c +4 data.json > clean.json |
本文余下的部分是为上面这张表不管用的情况准备的:看着像 BOM 其实不是的报错、反复把 BOM 塞回去的源头,以及那个「删掉它才是 bug」的唯一格式。至于 BOM 究竟是什么、新建文件该不该带上它,UTF-8 vs UTF-16 vs Unicode 编码完全指南 已经讲过。本文默认你手上这个 BOM 已经弄坏了什么东西。
下文所有实测都跑在 node v25.8.2 与 Python 3.14.5 上。
1. 在怪罪 BOM 之前,报错本身已经排除了什么
搜索位置 0 的 JSON 报错的人,大多数其实并没有 BOM。有四种不同的问题会产生形状相同的报错信息,而扫一眼引号里的那个字符就能把它们分开。下面是 V8 实际输出的字符串原文:
| 报错文本 | 真实原因 | 下一步 |
|---|---|---|
Unexpected token '', "{"a":1}" is not valid JSON | 字节 0 处的 UTF-8 BOM | 见第 2 节 |
Unexpected token '<', "<!DOCTYPE "... is not valid JSON | 响应体是 HTML:错误页、登录跳转或代理提示 | 打印原始响应体和状态码 |
Unexpected end of JSON input | 响应体是空的 | 检查状态码和 Content-Length |
"undefined" is not valid JSON | 你把一个从未赋过值的变量交给了 JSON.parse | 修调用方 |
规则短到可以背下来。读单引号里的那个字符。 < 说明你收到的是 HTML。一个选不中的方块、空白或问号,说明是 U+FEFF。引号里什么都没有,说明压根就没有输入。
旧措辞与新措辞
搜 json parse unexpected token position 0 得到的结果,大多是针对更早版本的 V8 报错写的:
SyntaxError: Unexpected token in JSON at position 0
那种措辞报出了偏移量,却把字符藏了起来。现在的措辞正好相反:它显示字符,还附上一小段输入内容,有用得多,但这也意味着你搜到的页面描述的可能是另一个运行时。如果你的报错给出的仍然是位置而不是字符,说明你用的是较旧的引擎,而下文的诊断思路不变。
2. 十秒确认它是不是 BOM
四种检查方式,大致按快慢排序。任意一种都能给出结论。
看前三个字节。
$ hexdump -C data.json | head -1
00000000 ef bb bf 7b 22 61 22 3a 31 7d |...{"a":1}|
7b(也就是 {)前面的 ef bb bf 就是 BOM。右侧 ASCII 列里的 ... 是 hexdump 在承认:这几个字节没有可打印的形态能给你看。
问 file。 它会直说,而且对文件类型的判断会彻底改变:
$ file data.json
data.json: Unicode text, UTF-8 (with BOM) text, with no line terminators
$ file clean.json
clean.json: JSON data
在 Node 里检查第一个码点。
const fs = require('fs');
const raw = fs.readFileSync('data.json', 'utf8');
console.log(raw.charCodeAt(0) === 0xFEFF); // true
看编辑器状态栏。 VS Code 会在右下角显示 UTF-8 with BOM,点一下还会给出「通过编码保存」的选项。文件之所以看着没问题,原因全在这里:编辑器知道,只是没提醒你。
对于本地 dump 不了的内容,可以粘进 Base64 在线编码解码 看字节层面的样子。载荷开头的 UTF-8 BOM 编码出来永远以 77u/ 开头,扫日志时认出这个前缀能省不少事。
3. 你的 BOM 是从哪来的
如果一个文件每小时都会被构建步骤重新生成,那么删掉它的 BOM 就是一个有效期一小时的修复。常见的生产者有:
- Excel 的「另存为 → CSV UTF-8」。 这是有意为之,不是 bug,第 7 节会解释原因。
- 记事本和其他 Windows 编辑器,它们把「UTF-8 with BOM」做成一个单独的保存选项,有时甚至是默认项。
- VS Code,当
files.encoding被设成utf8bom时。这个值可能躺在你的用户设置里,也可能提交在没人会去看的.vscode/settings.json里。 - PowerShell 的重定向。 在某些 PowerShell 版本里,
>和Out-File默认会写入 BOM,而仅限 Windows 的 5.x 系列和跨平台的 6/7 系列,两者的默认值并不一致。这一点别凭记忆下结论:写一个文件出来,用第 2 节的命令检查它的前三个字节。 - 手写的导出代码。 任何创建 UTF-8 编码器却没有明确指定是否输出签名的写入逻辑,都会继承所在框架挑的默认值,而各家框架挑的并不一样。老版本 .NET 和老版本 Java 的导出路径是常见嫌疑对象。
- 数据库与 BI 导出工具,它们经常带上 BOM,因为它们的主要消费方是电子表格。
如果文件来自合作方或供应商,而你改不了生产者,直接跳到第 4 节在读取时剥离。如果它来自你自己的仓库,第 9 节才是长久的答案。
4. 在 JavaScript 与 Node 里修复
困惑最集中的就是这里,因为 JavaScript 生态并没有统一的 BOM 策略。它有好几套,而且互相矛盾。同一个文件、同一个运行时,在 node v25.8.2 上实测:
| API | 对 BOM 的处理 | 随后的 JSON.parse |
|---|---|---|
fetch → res.json() | 剥离 | 成功 |
fs.readFileSync(f, 'utf8') | 保留 | 失败 |
new TextDecoder()(默认) | 剥离 | 成功 |
new TextDecoder('utf-8', { ignoreBOM: true }) | 保留 | 失败 |
require('./data.json') | 剥离 | 不适用,已经解析完了 |
import(..., { with: { type: 'json' } }) | 剥离 | 不适用,已经解析完了 |
从这张表里能推出两件事,两件都让人搭进去过整个下午。
ignoreBOM 的行为和它的名字相反
ignoreBOM: true 的意思不是「忽略 BOM」,而是「忽略 BOM 的特殊含义,把它当成普通字符保留下来」。真正会删掉它的是默认值 false。这个名字描述的是解码器忽略了什么,而不是你最终拿到什么;按字面去理解,你会得到一个原封不动保留了那个字节的解码器,而那个字节正是你想删掉的。
为什么浏览器里好好的,一到 Node 就炸
这个问题被报告得最多的形态就是它:同一个 JSON URL 在前端代码里解析得好好的,一到 Node 脚本从磁盘读取就抛异常。文件本身什么都没变。res.json() 走的是和 TextDecoder 同一套解码机制,在解码途中就把 BOM 丢掉了;而 fs.readFileSync(path, 'utf8') 是一次忠实的解码,文件里有什么字符就给你什么字符,U+FEFF 也不例外。
同样的不对称也解释了为什么 require('./config.json') 能用,而 JSON.parse(fs.readFileSync('./config.json', 'utf8')) 不能。Node 的 JSON 模块加载器会剥离 BOM,手写的那条路径不会。
剥离它
const fs = require('fs');
const raw = fs.readFileSync('data.json', 'utf8');
const data = JSON.parse(raw.replace(/^/, ''));
一定要用 ^ 锚定。不带锚点的全局替换会把字符串值内部合法的 U+FEFF 一起删掉,那是数据丢失,不是修复。
还有一种恰好能用的写法:JSON.parse(raw.trim()) 同样能成功,因为 ECMAScript 把 U+FEFF 归类为空白字符,String.prototype.trim 会把它去掉。这是实测行为,但它是 JavaScript 规范的一个巧合,换个语言就不成立。Python 的 str.strip() 会把 U+FEFF 原封不动留在原处。
如果你想确认剥离后的结果是真的合法,而不只是「没抛异常」,把它粘进 JSON 格式化工具 验证一下。BOM 一旦排除,位置 0 上剩下的嫌疑就是那些常规的转义问题了,JSON 字符串转义完全指南 里有完整说明。
5. 在 Python 里修复:utf-8-sig
Python 是唯一在报错信息里直接点名这个问题的运行时。把一个带 BOM 的文件按普通 UTF-8 打开,json 会在同一句话里既说清原因,又给出解法:
JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)
如果你是搜 unexpected utf-8 bom 找到这里的,这条字符串就是它的出处。它指向的那个编解码器会把 BOM 当作签名读走并丢弃:
import json
with open('data.json', encoding='utf-8-sig') as f:
data = json.load(f)
utf-8-sig 对没有 BOM 的文件同样安全。有 BOM 就剥掉一个,没有就等同于普通 UTF-8。对任何不是你自己产出的文件,它都是正确的默认选择。
字节和文本的行为不一样
这里有一处不对称,会让这个 bug 看起来时有时无:
import json
json.loads(open('data.json', 'rb').read()) # {'a': 1} 正常
json.loads(open('data.json', encoding='utf-8').read()) # 抛出上面那个错
json.loads 拿到的是 bytes 时,会先跑一遍编码探测,认出 BOM,替你用 utf-8-sig 解码。而你交给它的如果是已经解码好的 str,就没什么可探测的了,U+FEFF 会一路走到解析器面前。两条看起来等价的代码路径,其中一条悄悄把这种情况处理掉了。
有意写入 BOM
同一个编解码器反过来也能用,这正是给 Excel 生产文件的办法:
with open('report.csv', 'w', encoding='utf-8-sig', newline='') as f:
f.write('name\n')
这个文件会以 ef bb bf 开头。什么时候需要这样做,第 7 节会讲。
CSV 陷阱
csv.DictReader 处理带 BOM 的文本时,做的正是一个正确的 CSV 解析器该做的事,然后产出一个谁也匹配不上的 key:
import csv, io
data = 'name,age\nAlice,30\n'
print(list(next(csv.DictReader(io.StringIO(data))).keys()))
# ['name', 'age']
你的第一列不叫 name。它是 U+FEFF 后面跟着 name,于是每一次 row['name'] 查找都抛 KeyError,而表头在你手边任何一个调试器里都打印得完全正确。用 encoding='utf-8-sig' 打开文件,就能在 reader 看到它之前把它去掉。
6. 在 Java、Go、PHP 和 shell 里移除 BOM
这些修复都是同一件事做在不同层:删掉三个字节(EF BB BF),或者删掉一个字符(U+FEFF),取决于你手上拿的是字节还是文本。如果你的语言没有能识别 BOM 的编解码器,就手动做。
Java 会把 BOM 解码成开头的一个 字符:
String text = Files.readString(path, StandardCharsets.UTF_8);
if (!text.isEmpty() && text.charAt(0) == '') {
text = text.substring(1);
}
Go,在反序列化之前于字节层面处理:
raw, err := os.ReadFile("data.json")
if err != nil {
return err
}
raw = bytes.TrimPrefix(raw, []byte{0xEF, 0xBB, 0xBF})
var v map[string]any
err = json.Unmarshal(raw, &v)
PHP,用一个按字节锚定的正则:
$raw = file_get_contents('data.json');
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw);
$data = json_decode($raw, true);
如果要从文件而不是变量里移除 BOM,下面四条命令都在一个以 ef bb bf 开头的文件上验证过:
# 原地修改,GNU sed(Linux)。转义由 shell 展开,不是 sed。
sed -i $'1s/^\xEF\xBB\xBF//' data.json
# 原地修改,BSD sed(macOS)
sed -i '' $'1s/^\xEF\xBB\xBF//' data.json
# 原地修改,任何装了 Perl 的环境。只处理第一行。
perl -i -pe 's/^\x{ef}\x{bb}\x{bf}// if $. == 1' data.json
# 复制一份,跳过前三个字节。只有在确认 BOM 存在时才安全。
tail -c +4 data.json > clean.json
tail 那条最粗暴:不管前三个字节是不是 BOM,它都照删不误。先用第 2 节确认。
7. CSV 例外:Excel 需要保留 BOM 的场景
上面的一切都把 BOM 当成损伤。但有一个地方它是承重的,删掉它反而会弄坏一个本来正常的文件。
搜 csv bom excel 会分裂成两种完全相反的抱怨,这本身就是个好信号:说明同一条规则被用反了方向。
- 「我的 CSV 在 Excel 里打开显示成
é和æ¥æ¬èª,不是真正的字符。」缺的就是 BOM。 - 「我的第一列叫
name,脚本找不到它。」多的就是 BOM。
为什么 Excel 需要它
Windows 上的 Excel 没有可靠办法判断一个 CSV 是不是 UTF-8。没有头部、没有声明、没有元数据:.csv 文件就是一堆字节。缺少信号时它会回退到系统区域设置(在美国和西欧是 Windows-1252,在俄罗斯是 Windows-1251),于是每一个非 ASCII 字符都会出错。BOM 就是那个信号。开头三个字节,Excel 就能正确按 UTF-8 读取。
这让 CSV 里的 BOM 成了一个特性而不是缺陷。判断标准就一句话:
写给机器解析的,剥掉 BOM。写给人在 Excel 里双击打开的,留着它。
另一侧的失败
把同一个文件喂给解析器,BOM 就会并进你的第一个表头单元格。在 Node 里:
const header = 'name,age'.split(',');
console.log(JSON.stringify(header)); // ["name","age"]
const row = { 'name': 'Alice', age: 30 };
console.log(row.name); // undefined
row.name 是 undefined,而这个 key 在你的日志、调试器和 console.table 里都打印成 name。这和第 5 节里 Python 的 KeyError 是同一种形状的 bug。看到「字段名对得上但取不到值」,先怀疑 BOM。
我们自己的两个转换器有意各站一边。CSV 转 JSON 在线工具 会在解析前剥掉输入开头的 BOM,所以直接从 Excel 导出的文件得到的是 name 而不是 name。反方向上,JSON 转 CSV 在线工具 把 BOM 做成一个显式开关,它的 Excel 预设会把这个开关连同分号分隔符和 CRLF 换行一起打开,欧洲 Excel 区域设置需要的正是这个组合。关于分隔符、引号和类型推断这一类更大范围的转换决策,CSV 与 JSON 互转指南 里有完整讲解。
8. 不止 JSON:BOM 还会在哪冒出来
JSON 会大声嚷嚷。其他格式不会。
Shell 脚本。 BOM 夹在文件开头和 #! 之间,于是内核根本看不到 shebang,也就永远不会启动你指定的解释器。在 macOS 上实测的结果是 shell 回退到 sh,并把 shebang 那一行当成一个不存在的文件报出来:
./bom.sh: line 1: #!/bin/sh: No such file or directory
脚本随后还是在错误的解释器下跑起来了,这比直接失败更糟。其他系统的措辞不同,最有名的是报 bad interpreter 错误。如果一个以完全正确的 #!/usr/bin/env python3 开头的脚本坚称这个路径不存在,去看字节。
PHP。 <?php ... ?> 之外的一切都是输出,而开标签之前的 BOM 就是在你的代码运行之前发出的三个字节的输出。接下来第一次调用 header()、session_start() 或 setcookie() 就会以经典的「headers already sent」警告失败,并指向某个文件的第 1 行,而那一行看上去是空的。
.env 文件以及任何键值格式。 机制和 CSV 那一例完全相同:你的第一个变量不是 DATABASE_URL,而是 U+FEFF 后面跟着 DATABASE_URL,于是查找落空,而文件用肉眼读起来毫无问题。后面每一个变量都正常,这让它看起来像是某一项配置单独出了问题。
XML 是反方向的例外。 XML 规范明确允许文档开头出现 UTF-8 BOM,把它作为编码自动检测的一部分,解析器必须能够应付。测试中 Python 的 xml.etree.ElementTree 毫无怨言地接受了带 BOM 的文档。如果 XML 解析失败,原因多半不是 BOM。
9. 从源头上堵住
删掉一次 BOM 只管到下一次生成。要让它别再回来,得动写文件的那一端。
在 .editorconfig 里锁定编码。 charset 属性把 utf-8 和 utf-8-bom 当作两个不同的值,所以写明你要哪一个不会有歧义:
[*]
charset = utf-8
检查会覆盖它的编辑器设置。 在 VS Code 里是 "files.encoding": "utf8",要提防的值是 utf8bom。除了用户设置,还要检查工作区的 .vscode/settings.json,因为提交进仓库的工作区设置会悄无声息地作用于团队里每一个人。
在 CI 或 pre-commit 钩子里扫描。 下面这段可移植、无依赖,发现问题时以非零状态退出:
#!/bin/sh
# 只要有被跟踪的文件以 EF BB BF 开头就失败
found=0
for f in $(git ls-files '*.json' '*.md' '*.sh'); do
if [ "$(head -c3 "$f" | od -An -tx1 | tr -d '[:space:]')" = "efbbbf" ]; then
echo "BOM: $f"
found=1
fi
done
exit $found
两个方向都验证过:仓库里存在带 BOM 的文件时,它会列出这些路径并以 1 退出;文件干净之后则以 0 退出。
把唯一允许的例外写下来。 「哪里都不许有 BOM」这条规则,会在第一次有人需要导出电子表格时被打破,之后就会被普遍无视。不如直接写明例外:为 Excel 生成的 CSV 文件允许带 BOM,其他地方一律不允许。把导出目录从扫描器里排除掉,这条规则才能在现实中活下来。
10. 六十秒的二分排查流程
按顺序执行。每一步要么直接结束排查,要么把一个更小的问题交给下一步。
- 读字符,不是读位置。 见第 1 节。
<说明是 HTML,你到这里就结束了。引号里空空如也,说明响应体是空的。一个读不出来的方块,说明得继续往下走。 - 确认字节。
hexdump -C file | head -1。如果前三个字节不是ef bb bf,就此打住:这不是 BOM,下面的内容一条都帮不上忙。 - 找到它是从哪进来的。 文件在磁盘上就带着 BOM,还是磁盘上干干净净、等你的代码拿到手时才带上了 BOM?磁盘上是干净的,就说明你的流水线里有环节在加它。
- 选定一侧来修。 生产者是供应商、是上传行为、是你管不着的构建步骤,就在读取端剥离。生产者是你自己的,就去修生产者,因为读取端的修复得在每一个读取方那里重复一遍。
- 在解码边界上修,别修得更深。 在
open()调用处写encoding='utf-8-sig',而不是在三层函数之后对某个字符串做.lstrip()。修在栈的深处,意味着下一条读这个文件的代码路径还得把这个 bug 重新发现一遍。 - 验证字节确实变了。 重跑第 2 步。一个只在某一条代码路径上生效、却没有改动文件本身的修复,到下一条路径上还会挂。
- 加上扫描器。 见第 9 节。否则下个季度你会把这一整套再走一遍。
常见问题
UTF-8 BOM 是必需的吗?
不是。UTF-8 只有一种字节序,没有什么需要靠一个标记来消歧。Unicode 允许把 UTF-8 BOM 当作编码签名,但并不推荐;JSON 则干脆禁止:RFC 8259 规定,实现不得给 JSON 文本添加字节序标记。
为什么文件在编辑器里看着没问题,却解析失败?
因为 U+FEFF 渲染出来什么都不是。认得它的编辑器会把这个字符藏起来,改为在状态栏里提一句 UTF-8 with BOM。不认得的编辑器就干脆画零个像素。cat、less 以及代码评审里的 diff,看上去也都一模一样。只有字节层面的视图才能把它暴露出来。
JSON.parse 会自动剥离 BOM 吗?
从不。JSON.parse 接受的是字符串,无论 U+FEFF 出现在哪里,它都当作意外字符处理。真正剥离它的是上一层:fetch 之后的 res.json()、Node 用来加载 .json 文件的 require(),以及采用默认设置的 TextDecoder,都会在解析器看到任何东西之前把它去掉。
我该不该从 CSV 文件里移除 BOM?
取决于谁来打开这个文件。任何解析器都会把 BOM 并进第一个列名,name 于是变成 name,每次查找都落空,这种场合要剥掉。而 Windows 上的 Excel 靠 BOM 判断 UTF-8,没有它就会把带重音的字符和中日韩字符弄成乱码,这种场合要保留。
BOM 和零宽空格是一回事吗?
同一个码点,职责不同。位于偏移量 0 的 U+FEFF 是字节序标记。出现在文档其他任何位置时,它是 ZERO WIDTH NO-BREAK SPACE(零宽不换行空格),这种用法已被 Unicode 废弃,改为推荐 U+2060 WORD JOINER。老的文本里仍然含有它,这正是 U+FEFF 会出现在文件中间的原因。
BOM 会影响 git diff 和文件体积吗?
磁盘上多三个字节,以及每次改动它时 diff 里多出的一行噪音。Git 比较的是字节,所以即便渲染出来的文本完全相同,加上或去掉 BOM 也会重写第 1 行。评审里那种谁也解释不清的一行改动,来源就在这里。