Skip to content
Назад к блогу
Руководства

UTF-8 BOM: исправление ошибок JSON.parse и CSV

UTF-8 BOM ломает JSON.parse в файле, который выглядит идеально. Как найти невидимые байты EF BB BF, убрать их в любом языке и когда Excel всё же требует их сохранить.

14 мин чтения

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 JSONUTF-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
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 и оставить его обычным символом». Убирает 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 приходят с двумя противоположными жалобами:

  1. «Мой CSV открывается в Excel с é и æ¥æ¬èª вместо нормальных символов». BOM отсутствует.
  2. «Первая колонка называется 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. Читайте символ, а не позицию. Раздел 1. < — это HTML, и здесь вы закончили. Пусто в кавычках — тело ответа было пустым. Нечитаемый квадратик — продолжаем.
  2. Подтвердите байты. hexdump -C file | head -1. Если первые три байта не ef bb bf, остановитесь: это не BOM, и ничего из написанного ниже не поможет.
  3. Найдите точку входа. BOM уже есть в файле на диске — или на диске файл чистый, а BOM появляется к моменту, когда до него добирается ваш код? Чистый файл на диске означает, что BOM дописывает что-то внутри вашего конвейера.
  4. Выберите одну сторону для исправления. Убирайте BOM при чтении, когда источник — вендор, пользовательская загрузка или шаг сборки, которым вы не владеете. Чините источник, когда он ваш: исправление на стороне чтения придется повторять у каждого читателя.
  5. Правьте на границе декодирования, а не глубже. encoding='utf-8-sig' в вызове open(), а не .lstrip() над строкой тремя функциями позже. Исправление в глубине стека означает, что следующий путь чтения этого файла заново откроет для себя тот же баг.
  6. Убедитесь, что байты изменились. Повторите шаг 2. Исправление, которое сработало в одном пути кода, но не тронуло файл, провалится в следующем.
  7. Добавьте проверку. Раздел 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, даже если отрисованный текст идентичен. Отсюда и берутся однострочные изменения, которые никто на ревью не может объяснить.

Теги: utf-8 bom json csv debugging character-encoding

Похожие статьи

Все статьи