UTF-8 BOM: исправление ошибок JSON.parse и CSV
Ошибка разбора JSON из-за UTF-8 BOM — это три байта, которых не видно. Файл открывается в редакторе чистым, cat печатает ровно то, что вы ожидали, линтер доволен, а JSON.parse всё равно падает на самом первом символе.
Исключение выглядит так:
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, и источник, который дописывает BOM обратно. Плюс один формат, где удаление BOM само по себе баг. Что такое BOM вообще и нужен ли он новому файлу, разбирает полный гид по кодировкам UTF-8, UTF-16 и Unicode. Здесь предполагается, что ваш BOM уже что-то сломал.
Все замеры на этой странице сделаны на node v25.8.2 и Python 3.14.5.
1. Что исключить, прежде чем винить BOM
Ошибку JSON на позиции 0 дает не только BOM. Одинаковое по форме сообщение приходит от четырех разных проблем, и различает их один взгляд на символ в кавычках. Вот буквальные строки, которые выдает V8:
| Текст ошибки | Что это на самом деле | Что делать дальше |
|---|---|---|
Unexpected token '', "{"a":1}" is not valid JSON | UTF-8 BOM в байте 0 | Раздел 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}|
ef bb bf перед 7b ({) — это и есть BOM. Точки в колонке ASCII справа стоят там, где hexdump не нашел печатаемого символа.
Спросите file. Он называет BOM прямым текстом и заодно меняет вердикт о типе файла:
$ 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 в правом нижнем углу, а по клику предлагает Save with encoding. Подпись мелкая и стоит в стороне от текста, поэтому файл и выглядел нормальным: про BOM редактор знал с самого начала.
Если payload нельзя скачать локально, а на байты посмотреть нужно, вставьте его в кодировщик и декодер Base64. UTF-8 BOM в начале payload всегда дает строку, которая начинается с 77u/. Этот префикс стоит запомнить: его видно прямо в строке лога.
3. Откуда взялся ваш BOM
Убирать BOM из файла, который сборка пересоздает каждый час, бессмысленно: со следующей сборкой он вернется. Обычные источники:
- Excel, Сохранить как → CSV UTF-8. Здесь так сделано намеренно, а не по ошибке; почему — в разделе 7.
- «Блокнот» и другие редакторы под Windows, где UTF-8 with BOM вынесен в отдельный пункт сохранения, а иногда стоит и по умолчанию.
- VS Code, когда
files.encodingвыставлен вutf8bom— либо в пользовательских настройках, либо в закоммиченном.vscode/settings.json, куда никто не заглядывает. - Перенаправление вывода в PowerShell.
>иOut-Fileв некоторых версиях PowerShell по умолчанию пишут BOM, причем умолчание различается между веткой 5.x (только Windows) и кроссплатформенной веткой 6/7. По памяти тут действовать не стоит: запишите один файл и проверьте его первые три байта командами из раздела 2. - Самописный код экспорта. Если при создании UTF-8-кодировщика не сказано явно, писать сигнатуру или нет, вступает в силу умолчание фреймворка — а у фреймворков они разные. Чаще всего попадаются старые пути экспорта на .NET и на Java.
- Инструменты экспорта из СУБД и BI-систем: они часто пишут BOM, потому что их основной потребитель — электронная таблица.
Если файл приходит от партнера или вендора и повлиять на источник нельзя, переходите к разделу 4 и убирайте BOM при чтении. Если файл лежит в вашем же репозитории, долгосрочное решение — в разделе 9.
4. Как это чинится в JavaScript и Node
Здесь путаницы больше всего: единой политики по BOM в экосистеме JavaScript нет, а те, что есть, друг с другом не согласны. Один и тот же файл, одна и та же среда выполнения, замерено на 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 и оставить его обычным символом». Убирает 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')) — нет. Загрузчик JSON-модулей в Node убирает 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 и на другие языки не переносится. Питоновский 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. Поэтому для любого файла, который сделали не вы, это разумное умолчание.
Байты и текст ведут себя по-разному
Из-за этой асимметрии баг выглядит плавающим:
import json
json.loads(open('data.json', 'rb').read()) # {'a': 1} works
json.loads(open('data.json', encoding='utf-8').read()) # raises the error above
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-парсер, и выдает ключ, в который никто не попадет:
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', и BOM исчезнет до того, как его увидит csv.DictReader.
6. Как убрать BOM в Java, Go, PHP и командной строке
Исправление везде одно, меняется только уровень: удалить три байта (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:
# In place, GNU sed (Linux). The shell expands the escapes, not sed.
sed -i $'1s/^\xEF\xBB\xBF//' data.json
# In place, BSD sed (macOS)
sed -i '' $'1s/^\xEF\xBB\xBF//' data.json
# In place, anywhere Perl exists. First line only.
perl -i -pe 's/^\x{ef}\x{bb}\x{bf}// if $. == 1' data.json
# Copy without the first three bytes. Only safe if you know a BOM is there.
tail -c +4 data.json > clean.json
Вариант с tail — самый грубый: он снимает три байта независимо от того, были они BOM или нет. Сначала проверьте по разделу 2.
7. Исключение — CSV: когда Excel требует оставить BOM
Всё, что выше, трактует BOM как поломку. Есть одно место, где он несущий: там его удаление ломает рабочий файл.
По запросу csv bom excel приходят с двумя противоположными жалобами:
- «Мой CSV открывается в Excel с
Ã©иæ¥æ¬èªвместо нормальных символов». BOM отсутствует. - «Первая колонка называется
name, и скрипт не может ее найти». BOM на месте.
Почему Excel его ждет
У Excel под Windows нет надежного способа узнать, что CSV записан в UTF-8. Ни заголовка, ни метаданных: файл .csv — это просто байты. Без сигнала он откатывается к системной локали: Windows-1252 в США и Западной Европе, Windows-1251 в России, — и каждый не-ASCII символ выходит искаженным. Этот сигнал и есть BOM. Три байта в начале — и Excel читает UTF-8 правильно.
Значит, BOM в CSV — возможность, а не дефект, и правило умещается в одну строку:
Файл пишется для разбора машиной — 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, хотя ключ печатается как name и в логах, и в отладчике, и в console.table. Форма та же, что у KeyError в Python из раздела 5. Симптом «имя поля совпадает, а значения нет» стоит с ходу считать признаком BOM.
Наши конвертеры учитывают обе стороны. Конвертер CSV в JSON снимает ведущий BOM со входных данных до разбора, поэтому файл прямо из Excel дает name, а не name. В обратную сторону конвертер JSON в CSV выносит BOM в явный переключатель, а его пресет для Excel включает BOM вместе с точкой с запятой в качестве разделителя и переводами строк CRLF — именно эта комбинация нужна европейским локалям Excel. Остальные решения при конвертации — разделители, кавычки, вывод типов — разобраны в гиде по конвертации CSV в JSON.
8. Не только JSON: где ещё вылезает BOM
JSON заявляет о проблеме громко. Остальные форматы — нет.
Скрипты для командной строки. 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 файла, у которого строка 1 выглядит пустой.
Файлы .env и любой формат «ключ — значение». Механизм тот же, что и с CSV: первая переменная у вас не DATABASE_URL, а U+FEFF и следом DATABASE_URL, поэтому поиск промахивается, хотя человеку файл читается правильно. Все остальные переменные при этом работают — и выглядит это как проблема одной конкретной настройки.
XML — исключение в другую сторону. Спецификация XML прямо разрешает UTF-8 BOM в начале документа как часть автоопределения кодировки, и парсеры обязаны с этим справляться. Питоновский 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 hook. Она переносима, не тянет зависимостей и завершается ненулевым кодом, когда что-то находит:
#!/bin/sh
# Fail if any tracked file begins with 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 нигде» нарушат в первый же раз, когда кому-то понадобится выгрузка для таблицы, а дальше его перестанут соблюдать вообще. Вместо этого сформулируйте исключение: BOM разрешен в CSV-файлах, которые делаются для Excel, и больше нигде. Исключите каталог выгрузок из проверки, и правило останется выполнимым.
10. Бисекция за шестьдесят секунд
Выполняйте по порядку. Каждый шаг либо заканчивает расследование, либо сужает задачу для следующего.
- Читайте символ, а не позицию. Раздел 1.
<— это HTML, и здесь вы закончили. Пусто в кавычках — тело ответа было пустым. Нечитаемый квадратик — продолжаем. - Подтвердите байты.
hexdump -C file | head -1. Если первые три байта неef bb bf, остановитесь: это не BOM, и ничего из написанного ниже не поможет. - Найдите точку входа. BOM уже есть в файле на диске — или на диске файл чистый, а BOM появляется к моменту, когда до него добирается ваш код? Чистый файл на диске означает, что BOM дописывает что-то внутри вашего конвейера.
- Выберите одну сторону для исправления. Убирайте BOM при чтении, когда источник — вендор, пользовательская загрузка или шаг сборки, которым вы не владеете. Чините источник, когда он ваш: исправление на стороне чтения придется повторять у каждого читателя.
- Правьте на границе декодирования, а не глубже.
encoding='utf-8-sig'в вызовеopen(), а не.lstrip()над строкой тремя функциями позже. Исправление в глубине стека означает, что следующий путь чтения этого файла заново откроет для себя тот же баг. - Убедитесь, что байты изменились. Повторите шаг 2. Исправление, которое сработало в одном пути кода, но не тронуло файл, провалится в следующем.
- Добавьте проверку. Раздел 9. Иначе через квартал вы проделаете всё это заново.
FAQ
Обязателен ли 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 неожиданным символом, где бы тот ни встретился. Убирает его слой выше: res.json() после fetch, require() в Node для файлов .json и TextDecoder с настройками по умолчанию — все они снимают BOM до того, как парсер вообще что-то увидит.
Нужно ли убирать BOM из CSV-файлов?
Зависит от того, кто будет открывать файл. Любой парсер вклеит BOM в имя первой колонки: name превращается в name, и все обращения промахиваются. Там — убирайте. Excel под Windows по BOM определяет UTF-8 и без него портит символы с диакритикой и CJK. Там — оставляйте.
BOM — это то же самое, что пробел нулевой ширины?
Кодовая точка одна, роли разные. U+FEFF по смещению 0 — это метка порядка байтов. В любом другом месте документа это ZERO WIDTH NO-BREAK SPACE, и такое применение Unicode объявил устаревшим в пользу U+2060 WORD JOINER. В старых текстах он по-прежнему встречается — отсюда и U+FEFF посреди файлов.
Влияет ли BOM на git diff и размер файла?
Три байта на диске и одна шумная строка в каждом diff, который его затрагивает. Git сравнивает байты, поэтому добавление или удаление BOM переписывает строку 1, даже если отрисованный текст идентичен. Отсюда и берутся однострочные изменения, которые никто на ревью не может объяснить.