UTF-8 BOM で JSON.parse が失敗する原因と直し方
UTF-8 BOM による JSON パースエラーの正体は、目に見えない 3 バイトだ。エディタで開けば中身はきれいなままだし、cat の出力も期待どおりになる。リンタも何も言わない。それでも JSON.parse は、いちばん最初の 1 文字で例外を投げる。
node v25.8.2 で実測すると、投げられる例外はこうなる。
SyntaxError: Unexpected token '', "{"a":1}" is not valid JSON
引用符の中にターミナルが何を描いていようと、そこにあるのは 1 文字だけだ。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 ではないエラー、消しても消しても付け直してくる発生源、取り除くほうがバグになる唯一のフォーマット。BOM とは何か、新しく作るファイルに付けるべきかどうかについては UTF-8 vs UTF-16 vs Unicode エンコーディングガイドで扱っている。ここで扱うのは、すでに何かが壊れている状態だ。
以下の実測値はすべて node v25.8.2 と Python 3.14.5 で取得した。
1. BOM を疑う前に、エラーメッセージが否定するもの
位置 0 の JSON エラーを検索している人の大半は、そもそも BOM を踏んでいない。互いに無関係な 4 つの問題が同じ形のメッセージを出すが、引用符の中の文字を一目見れば区別がつく。以下は V8 が実際に出力する文字列そのものだ。
| エラーメッセージ | 実際の正体 | 次にやること |
|---|---|---|
Unexpected token '', "{"a":1}" is not valid JSON | 先頭バイトの 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 に渡した | 呼び出し側を直す |
ルールは 1 行で済む。シングルクォートの中の文字を読む。 < なら受け取ったのは HTML。箱(いわゆる豆腐)や空白、選択できない疑問符なら U+FEFF。そもそも何も引用されていないなら、入力自体が存在しなかった。
昔の文言と今の文言
json parse unexpected token position 0 で出てくる検索結果は、その多くが古い V8 のメッセージを前提に書かれている。
SyntaxError: Unexpected token in JSON at position 0
この文言はオフセットを示す代わりに、肝心の文字を隠していた。今の文言はその逆で、文字と入力の一部を示す。こちらのほうがはるかに役に立つが、裏を返せば、検索でたどり着いたページが自分とは違うランタイムの話をしている可能性がある。手元のエラーが文字ではなく位置を示しているなら、それは古いエンジンで動いているというだけで、以下の切り分けは何も変わらない。
2. BOM かどうかを 10 秒で確定させる
確認方法は 4 つあり、おおむね速い順に並べた。どれか 1 つで決着する。
先頭 3 バイトを見る。
$ 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 と表示し、そこをクリックすると「エンコード付きで保存」が選べる。ファイルが正常に見えていた理由も、このラベルで説明がつく。エディタは最初から把握していて、ラベル以外の形では伝えていなかっただけだ。
手元でダンプできないものをバイト単位で見たいときは、Base64デコード・エンコードツールに貼り付ければいい。ペイロードの先頭に UTF-8 BOM があると、Base64 の結果は必ず 77u/ で始まる文字列になる。ログの 1 行の中でこれに気づけると役に立つ。
3. その BOM はどこから来たのか
1 時間おきにビルドが再生成するファイルから BOM を取り除いても、その修正の寿命は 1 時間だ。よくある発生源は次のとおり。
- Excel の「名前を付けて保存 → CSV UTF-8」。 これは意図的な仕様であってバグではない。理由はセクション 7 で説明する。
- メモ帳をはじめとする Windows のエディタ。 「UTF-8(BOM 付き)」を独立した保存オプションとして提供し、それが既定になっていることもある。
- VS Code。
files.encodingがutf8bomになっている場合。ユーザー設定のこともあれば、誰も見に行かない.vscode/settings.jsonにコミットされていることもある。 - PowerShell のリダイレクト。 バージョンによっては
>とOut-Fileが既定で BOM を書き込む。しかもその既定値は、Windows 専用の 5.x 系とクロスプラットフォームの 6/7 系で異なる。ここを記憶で判断してはいけない。実際に 1 ファイル書き出して、セクション 2 のコマンドで先頭 3 バイトを確認すること。 - 自前のエクスポート処理。 シグネチャを出力するかどうかを明示せずに 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' } }) | 除去される | 該当なし。すでにパース済み |
この表から言えることが 2 つある。どちらも半日を溶かす類のものだ。
ignoreBOM は名前と逆の動きをする
ignoreBOM: true は「BOM を無視する」という意味ではない。「BOM の特別な意味づけを無視して、ただの 1 文字として保持する」という意味だ。取り除くのは、既定値である 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 のないファイルに対しても安全だ。あれば取り除き、なければ素の UTF-8 として振る舞う。だから、自分で作ったわけではないファイルを読むときの既定値としては、これが正解になる。
bytes とテキストでは挙動が変わる
ここにも非対称性がある。バグが再現したりしなかったりするように見えるのは、これが原因だ。
import json
json.loads(open('data.json', 'rb').read()) # {'a': 1} 成功する
json.loads(open('data.json', encoding='utf-8').read()) # 上のエラーが発生する
bytes を渡された json.loads は、まずエンコーディング判定を走らせ、BOM を見つけて utf-8-sig でデコードする。すでにデコード済みの str を渡すと、判定すべきものは何も残っていないので、U+FEFF はそのままパーサに届く。等価に見える 2 つの経路のうち、片方だけが黙って後始末をしている。
意図的に BOM を書き込む
同じコーデックは逆方向にも働く。Excel 向けのファイルはこうして作る。
with open('report.csv', 'w', encoding='utf-8-sig', newline='') as f:
f.write('name\n')
このファイルは ef bb bf で始まる。そうしたい場面についてはセクション 7 で扱う。
CSV の罠
BOM 付きのテキストを読ませた csv.DictReader は、CSV パーサとして何ひとつ間違っていない処理をしたうえで、どこからも一致させられないキーを作り出す。
import csv, io
data = 'name,age\nAlice,30\n'
print(list(next(csv.DictReader(io.StringIO(data))).keys()))
# ['name', 'age']
1 列目は name ではない。U+FEFF に name が続いたものだ。だから row['name'] の参照はことごとく KeyError を投げるのに、ヘッダーはどのデバッガで見ても正しく表示される。encoding='utf-8-sig' でファイルを開けば、リーダーが目にする前に取り除かれる。
6. Java・Go・PHP・シェルでの除去
どの対処も、層が違うだけで中身は同じだ。手元にあるのがバイト列なら 3 バイト(EF BB BF)を、テキストなら 1 文字(U+FEFF)を削る。BOM を理解するコーデックが言語側にないなら、手作業でやる。
Java は BOM を先頭の という 1 文字にデコードする。
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 を取り除く場合は次の 4 つ。いずれも ef bb bf で始まるファイルに対して実際に確認したコマンドだ。
# その場で書き換える、GNU sed(Linux)。エスケープを展開するのは sed ではなくシェル。
sed -i $'1s/^\xEF\xBB\xBF//' data.json
# その場で書き換える、BSD sed(macOS)
sed -i '' $'1s/^\xEF\xBB\xBF//' data.json
# Perl があればどこでも使える、その場での書き換え。1 行目のみ。
perl -i -pe 's/^\x{ef}\x{bb}\x{bf}// if $. == 1' data.json
# 先頭 3 バイトを除いてコピーする。BOM があると分かっている場合のみ安全。
tail -c +4 data.json > clean.json
tail を使う形は乱暴だ。その 3 バイトが BOM だったかどうかに関係なく削り取る。先にセクション 2 で確認しておくこと。
7. CSV という例外——Excel には BOM を残す
ここまでは BOM を損傷として扱ってきた。だが 1 か所だけ、それが構造材として働いている場所がある。そこで削ると、正常に動いていたファイルが壊れる。
csv bom excel の検索は、正反対の 2 種類の苦情に割れる。同じルールが逆向きに適用されているときに出る形だ。
- 「Excel で CSV を開くと、本来の文字ではなく
éやæ¥æ¬èªが表示される」——BOM がない。 - 「1 列目の名前が
nameになっていて、スクリプトから見つけられない」——BOM がある。
Excel が BOM を欲しがる理由
Windows の Excel には、その CSV が UTF-8 であることを確実に知る手段がない。ヘッダーも宣言もメタデータもなく、.csv はただのバイト列だ。手がかりがなければシステムロケールにフォールバックする。米国や西欧なら Windows-1252、ロシアなら Windows-1251 といった具合で、ASCII 以外の文字はすべて化ける。その手がかりになるのが BOM だ。先頭に 3 バイトあるだけで、Excel は UTF-8 として正しく読む。
つまり CSV の BOM は欠陥ではなく機能であり、判断基準も一行で済む。
機械にパースさせるために書くなら BOM は取り除く。人間が Excel でダブルクリックするために書くなら残す。
反対側で起きる失敗
同じファイルをパーサに食わせると、BOM は 1 つ目のヘッダーセルに溶け込む。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 なのに、キーはログでもデバッガでも console.table でも name と表示される。セクション 5 の Python の KeyError とまったく同じ形のバグであり、だからこそ「フィールド名は合っているのに値が取れない」という症状は、見た瞬間に BOM を疑う価値がある。
私たちのコンバータは、この両側を意図的に作り分けている。CSV to JSON 変換ツールは入力の先頭にある BOM をパース前に取り除くので、Excel から出したままのファイルでも name ではなく name になる。逆方向の JSON to CSV 変換ツールでは BOM を明示的なトグルにしてあり、Excel 用プリセットではセミコロン区切りと CRLF 改行と合わせてこれがオンになる。欧州の Excel ロケールが実際に必要とする組み合わせがこれだ。区切り文字やクォート、型推論を含めた変換まわりの判断全般は、CSV から JSON への変換ガイドに一通りまとめてある。
8. JSON 以外——BOM が顔を出す場所
JSON は騒ぐ。他のフォーマットは黙っている。
シェルスクリプト。 BOM はファイルの先頭と #! の間に居座るので、カーネルはシバンを認識できず、指定したインタプリタは起動されない。macOS で実測したところ、シェルは sh にフォールバックし、シバンの行を「そんなファイルはない」と報告した。
./bom.sh: line 1: #!/bin/sh: No such file or directory
そのうえでスクリプトは、誤ったインタプリタのまま実行されてしまう。失敗するよりも質が悪い。他のシステムでは表現が異なり、いちばん有名なのは bad interpreter エラーだ。何の問題もない #!/usr/bin/env python3 で始まるスクリプトが、そのパスは存在しないと言い張るなら、バイト列を確認すること。
PHP。 <?php ... ?> の外側にあるものはすべて出力になる。開始タグの前にある BOM は、コードが動き出す前に送信される 3 バイトの出力だ。その結果、最初の header()、session_start()、setcookie() の呼び出しが、おなじみの「headers already sent」警告で失敗する。しかも警告が指しているのは、1 行目が空にしか見えないファイルの 1 行目だ。
.env ファイル、およびキーと値の形式全般。 仕組みは CSV の場合とまったく同じだ。最初の変数は DATABASE_URL ではなく、U+FEFF に DATABASE_URL が続いたものになる。人間が読む分には正しく見えるのに、参照だけが外れる。2 つ目以降の変数はすべて動くので、特定の 1 設定だけの問題に見えてしまう。
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. 60 秒でできる二分探索の手順
上から順に実行する。各ステップは、調査をそこで終わらせるか、次のステップにより小さな問題を渡すかのどちらかだ。
- 位置ではなく文字を読む。 セクション 1。
<なら HTML で、ここで終わり。何も引用されていないならボディが空。読めない箱が出ていれば次へ進む。 - バイト列を確認する。
hexdump -C file | head -1。先頭 3 バイトがef bb bfでなければ、そこで打ち切る。これは BOM ではないので、以下は何の役にも立たない。 - どこで混入したかを突き止める。 ディスク上のファイルがすでに BOM 付きなのか、それともディスク上はきれいで、コードが受け取る時点で BOM が付いているのか。ディスク上がきれいなら、パイプラインの途中の何かが付け足している。
- どちら側を直すか 1 つ選ぶ。 発生源がベンダーやアップロード、自分の管理外のビルド工程なら、読み込み時に取り除く。自分のものなら発生源を直す。読み込み側の対処は、読み込む場所すべてで繰り返す必要があるからだ。
- デコード境界で対処する。それより深いところではない。 3 つ先の関数で
.lstrip()を呼ぶのではなく、open()の呼び出しにencoding='utf-8-sig'を書く。スタックの深いところで直すと、次にそのファイルを読むコード経路が、同じバグを一から発見し直すことになる。 - バイト列が変わったことを確かめる。 ステップ 2 をもう一度走らせる。ある経路では効いていてもファイル自体は変わっていない、という修正は、次の経路で失敗する。
- 走査を追加する。 セクション 9。さもないと、来期にまた同じことを全部やる羽目になる。
FAQ
UTF-8 の BOM は必須か?
必須ではない。UTF-8 のバイト順は 1 種類しかないので、マークで区別すべきものが何もない。Unicode は UTF-8 BOM をエンコーディングのシグネチャとして許容してはいるが推奨しておらず、JSON に至っては明確に禁止している。RFC 8259 は、実装が JSON テキストにバイトオーダーマークを付加してはならないと定めている。
エディタでは正常に見えるのにパースに失敗するのはなぜか?
U+FEFF が何も描画しないからだ。この文字を認識するエディタは、文字自体を隠したうえで、代わりにステータスバーに UTF-8 with BOM と書く。認識しないエディタは、単に 1 ピクセルも描かない。cat も less もコードレビューの差分も、見た目はまったく同じだ。暴けるのは、バイト単位の表示だけだ。
JSON.parse が BOM を自動で取り除くことはあるか?
一度もない。JSON.parse は文字列を受け取り、U+FEFF がどこに現れようと想定外の文字として扱う。取り除いているのは 1 つ上の層だ。fetch のあとの res.json()、.json ファイルに対する Node の require()、既定設定の TextDecoder。いずれもパーサが何かを目にする前に取り除いている。
CSV ファイルから BOM を取り除くべきか?
誰がそのファイルを開くかによる。パーサは例外なく BOM を 1 列目の列名に取り込むので、name は name になり、参照はすべて外れる。こちらでは取り除く。一方、Windows の Excel は BOM を UTF-8 の判定に使っており、なければアクセント付き文字や CJK 文字が化ける。こちらでは残す。
BOM はゼロ幅スペースと同じものか?
コードポイントは同じで、役割が違う。オフセット 0 にある U+FEFF はバイトオーダーマークだ。文書のそれ以外の場所にあれば ZERO WIDTH NO-BREAK SPACE であり、この用法は Unicode が U+2060 WORD JOINER を推奨する形で非推奨にした。古いテキストには今も残っているので、ファイルの途中に U+FEFF が現れることがある。
BOM は git の差分やファイルサイズに影響するか?
ディスク上では 3 バイト、さらに触れるたびに差分へ 1 行のノイズが乗る。Git はバイト列を比較するので、表示上のテキストが同一でも、BOM の追加や削除は 1 行目を書き換える。レビューで誰も説明できない 1 行だけの変更は、これが出どころだ。