Skip to content
ブログに戻る
チュートリアル

UTF-8 BOM で JSON.parse が失敗する原因と削除方法

見た目は正常なファイルでも UTF-8 BOM があれば JSON.parse は失敗する。不可視の EF BB BF を見つけ、各言語で削除する方法と、Excel の CSV では逆に必要な理由を解説。

14 分で読める

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.2Python 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.encodingutf8bom になっている場合。ユーザー設定のこともあれば、誰も見に行かない .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)で読んだ実測結果がこれだ。

APIBOM の扱いその後の JSON.parse
fetchres.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 種類の苦情に割れる。同じルールが逆向きに適用されているときに出る形だ。

  1. 「Excel で CSV を開くと、本来の文字ではなく Ã©æ¥æ¬èª が表示される」——BOM がない。
  2. 「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.nameundefined なのに、キーはログでもデバッガでも 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-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. 60 秒でできる二分探索の手順

上から順に実行する。各ステップは、調査をそこで終わらせるか、次のステップにより小さな問題を渡すかのどちらかだ。

  1. 位置ではなく文字を読む。 セクション 1。< なら HTML で、ここで終わり。何も引用されていないならボディが空。読めない箱が出ていれば次へ進む。
  2. バイト列を確認する。 hexdump -C file | head -1。先頭 3 バイトが ef bb bf でなければ、そこで打ち切る。これは BOM ではないので、以下は何の役にも立たない。
  3. どこで混入したかを突き止める。 ディスク上のファイルがすでに BOM 付きなのか、それともディスク上はきれいで、コードが受け取る時点で BOM が付いているのか。ディスク上がきれいなら、パイプラインの途中の何かが付け足している。
  4. どちら側を直すか 1 つ選ぶ。 発生源がベンダーやアップロード、自分の管理外のビルド工程なら、読み込み時に取り除く。自分のものなら発生源を直す。読み込み側の対処は、読み込む場所すべてで繰り返す必要があるからだ。
  5. デコード境界で対処する。それより深いところではない。 3 つ先の関数で .lstrip() を呼ぶのではなく、open() の呼び出しに encoding='utf-8-sig' を書く。スタックの深いところで直すと、次にそのファイルを読むコード経路が、同じバグを一から発見し直すことになる。
  6. バイト列が変わったことを確かめる。 ステップ 2 をもう一度走らせる。ある経路では効いていてもファイル自体は変わっていない、という修正は、次の経路で失敗する。
  7. 走査を追加する。 セクション 9。さもないと、来期にまた同じことを全部やる羽目になる。

FAQ

UTF-8 の BOM は必須か?

必須ではない。UTF-8 のバイト順は 1 種類しかないので、マークで区別すべきものが何もない。Unicode は UTF-8 BOM をエンコーディングのシグネチャとして許容してはいるが推奨しておらず、JSON に至っては明確に禁止している。RFC 8259 は、実装が JSON テキストにバイトオーダーマークを付加してはならないと定めている。

エディタでは正常に見えるのにパースに失敗するのはなぜか?

U+FEFF が何も描画しないからだ。この文字を認識するエディタは、文字自体を隠したうえで、代わりにステータスバーに UTF-8 with BOM と書く。認識しないエディタは、単に 1 ピクセルも描かない。catless もコードレビューの差分も、見た目はまったく同じだ。暴けるのは、バイト単位の表示だけだ。

JSON.parse が BOM を自動で取り除くことはあるか?

一度もない。JSON.parse は文字列を受け取り、U+FEFF がどこに現れようと想定外の文字として扱う。取り除いているのは 1 つ上の層だ。fetch のあとの res.json().json ファイルに対する Node の require()、既定設定の TextDecoder。いずれもパーサが何かを目にする前に取り除いている。

CSV ファイルから BOM を取り除くべきか?

誰がそのファイルを開くかによる。パーサは例外なく BOM を 1 列目の列名に取り込むので、namename になり、参照はすべて外れる。こちらでは取り除く。一方、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 行だけの変更は、これが出どころだ。

タグ: utf-8 bom json csv debugging character-encoding