Skip to content
返回博客
教程

UTF-8 BOM 头:修复 JSON 解析报错与 CSV 乱码

文件看着完全正常,JSON.parse 却报错,元凶常是不可见的 EF BB BF。教你 10 秒确认 BOM、在各语言里去除它,以及 Excel 打开 CSV 时为何反而必须保留。免费在线工具。

14 分钟阅读

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.2Python 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
fetchres.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 会分裂成两种完全相反的抱怨,这本身就是个好信号:说明同一条规则被用反了方向。

  1. 「我的 CSV 在 Excel 里打开显示成 Ã©æ¥æ¬èª,不是真正的字符。」缺的就是 BOM。
  2. 「我的第一列叫 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.nameundefined,而这个 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-8utf-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. 读字符,不是读位置。 见第 1 节。< 说明是 HTML,你到这里就结束了。引号里空空如也,说明响应体是空的。一个读不出来的方块,说明得继续往下走。
  2. 确认字节。 hexdump -C file | head -1。如果前三个字节不是 ef bb bf,就此打住:这不是 BOM,下面的内容一条都帮不上忙。
  3. 找到它是从哪进来的。 文件在磁盘上就带着 BOM,还是磁盘上干干净净、等你的代码拿到手时才带上了 BOM?磁盘上是干净的,就说明你的流水线里有环节在加它。
  4. 选定一侧来修。 生产者是供应商、是上传行为、是你管不着的构建步骤,就在读取端剥离。生产者是你自己的,就去修生产者,因为读取端的修复得在每一个读取方那里重复一遍。
  5. 在解码边界上修,别修得更深。open() 调用处写 encoding='utf-8-sig',而不是在三层函数之后对某个字符串做 .lstrip()。修在栈的深处,意味着下一条读这个文件的代码路径还得把这个 bug 重新发现一遍。
  6. 验证字节确实变了。 重跑第 2 步。一个只在某一条代码路径上生效、却没有改动文件本身的修复,到下一条路径上还会挂。
  7. 加上扫描器。 见第 9 节。否则下个季度你会把这一整套再走一遍。

常见问题

UTF-8 BOM 是必需的吗?

不是。UTF-8 只有一种字节序,没有什么需要靠一个标记来消歧。Unicode 允许把 UTF-8 BOM 当作编码签名,但并不推荐;JSON 则干脆禁止:RFC 8259 规定,实现不得给 JSON 文本添加字节序标记。

为什么文件在编辑器里看着没问题,却解析失败?

因为 U+FEFF 渲染出来什么都不是。认得它的编辑器会把这个字符藏起来,改为在状态栏里提一句 UTF-8 with BOM。不认得的编辑器就干脆画零个像素。catless 以及代码评审里的 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 行。评审里那种谁也解释不清的一行改动,来源就在这里。

标签: utf-8 bom json csv debugging character-encoding