BOM UTF-8: corrigir erros de JSON.parse e CSV no Excel
Por trás de um erro de parse de JSON causado por BOM UTF-8 estão três bytes que você não consegue ver. O arquivo abre limpo no seu editor, o cat imprime exatamente o que você espera, o linter não reclama de nada, e mesmo assim o JSON.parse estoura logo no primeiro caractere.
Medido no node v25.8.2, a exceção sai assim:
SyntaxError: Unexpected token '', "{"a":1}" is not valid JSON
Seja lá o que o seu terminal desenhou dentro daquelas aspas, é um único caractere: U+FEFF, gravado como os bytes EF BB BF. O JSON estrito não tem lugar para ele. Na posição 0 um parser espera {, [, um dígito, uma aspa ou espaço em branco, e U+FEFF não é nenhum desses.
Se você já sabe que é um BOM, escolha o lado que está sob o seu controle:
| Onde você pode mexer | A correção |
|---|---|
| Node, lendo um arquivo | JSON.parse(raw.replace(/^/, '')) |
| Python, lendo um arquivo | open(path, encoding='utf-8-sig') |
| O arquivo em disco | tail -c +4 data.json > clean.json |
O resto desta página é para quando isso não resolve: o erro que parece um BOM e não é, a origem que insiste em recolocá-lo, e o único formato em que remover o BOM é justamente o que quebra o arquivo. Para entender o que é um BOM e se um arquivo novo deveria ter um, o guia completo de codificação UTF-8 vs UTF-16 cobre esse terreno. Aqui a premissa é que o seu já quebrou alguma coisa.
Tudo o que foi medido abaixo rodou em node v25.8.2 e Python 3.14.5.
1. O que a sua mensagem de erro descarta antes de você culpar o BOM
Boa parte dos erros de JSON na posição 0 não envolve BOM nenhum. Quatro problemas diferentes produzem uma mensagem com o mesmo formato, e uma olhada no caractere entre aspas já separa os casos. Estas são as strings literais que o V8 emite:
| Texto do erro | O que é de fato | Próximo passo |
|---|---|---|
Unexpected token '', "{"a":1}" is not valid JSON | BOM UTF-8 no byte 0 | Seção 2 |
Unexpected token '<', "<!DOCTYPE "... is not valid JSON | A resposta era HTML: uma página de erro, um redirecionamento de login, um aviso de proxy | Registre o corpo bruto e o código de status |
Unexpected end of JSON input | O corpo estava vazio | Confira o código de status e o Content-Length |
"undefined" is not valid JSON | Você entregou ao JSON.parse uma variável que nunca foi atribuída | Corrija quem chamou |
A regra é curta o bastante para decorar. Leia o caractere entre as aspas simples. < significa que você recebeu HTML. Um quadradinho, um vazio ou um ponto de interrogação que você não consegue selecionar significa U+FEFF. Nada entre aspas significa que não havia entrada nenhuma.
A mensagem antiga e a mensagem atual
Os resultados de busca para json parse unexpected token position 0 foram quase todos escritos contra uma mensagem mais antiga do V8:
SyntaxError: Unexpected token in JSON at position 0
Aquela redação dizia o offset e escondia o caractere. A atual faz o contrário: mostra o caractere e um trecho da entrada, o que é bem mais útil, mas significa que a página em que você caiu pode estar descrevendo um runtime que não é o seu. Se o seu erro ainda cita uma posição em vez de um caractere, você está num engine mais antigo, e o diagnóstico abaixo continua valendo.
2. Confirme que é um BOM em dez segundos
Quatro verificações, mais ou menos em ordem de rapidez. Qualquer uma delas encerra a dúvida.
Olhe os três primeiros bytes.
$ hexdump -C data.json | head -1
00000000 ef bb bf 7b 22 61 22 3a 31 7d |...{"a":1}|
O ef bb bf antes do 7b ({) é o BOM. Os ... na coluna ASCII à direita são o hexdump admitindo que não tem nada imprimível para exibir.
Pergunte ao file. Ele responde na lata, e muda completamente de opinião sobre o tipo do arquivo:
$ file data.json
data.json: Unicode text, UTF-8 (with BOM) text, with no line terminators
$ file clean.json
clean.json: JSON data
Verifique o primeiro code point no Node.
const fs = require('fs');
const raw = fs.readFileSync('data.json', 'utf8');
console.log(raw.charCodeAt(0) === 0xFEFF); // true
Leia a barra de status do editor. O VS Code mostra UTF-8 with BOM no canto inferior direito, e clicar ali oferece Save with encoding. Esse rótulo é justamente o motivo de o arquivo parecer normal: o seu editor sabia e não falou mais alto.
Para enxergar em nível de bytes algo que você não consegue dumpar localmente, cole no codificador e decodificador Base64. Um BOM UTF-8 no começo de um payload sempre codifica para uma string que começa com 77u/, e reconhecer isso numa linha de log ajuda bastante.
3. De onde veio o seu BOM
Remover o BOM de um arquivo que um passo de build regenera de hora em hora é uma correção com uma hora de vida útil. Os produtores mais comuns:
- O Salvar como → CSV UTF-8 do Excel. Esse é proposital, não é bug, e a seção 7 explica por quê.
- O Bloco de Notas e outros editores do Windows, que oferecem UTF-8 com BOM como opção de salvamento separada, às vezes como a padrão.
- O VS Code, quando
files.encodingestá emutf8bom, seja nas suas configurações de usuário, seja commitado no.vscode/settings.json, onde ninguém olha. - Redirecionamento de shell no PowerShell. O
>e oOut-Filegravam um BOM por padrão em algumas versões, e o padrão difere entre a linha 5.x (só Windows) e a linha 6/7 (multiplataforma). Não vá pela memória aqui: grave um arquivo e confira os três primeiros bytes com os comandos da seção 2. - Código de exportação feito à mão. Qualquer rotina de escrita que monte um encoder UTF-8 sem dizer se deve emitir a assinatura herda o padrão que aquele framework escolheu, e os frameworks não escolheram todos a mesma coisa. Caminhos de exportação em .NET antigo e Java antigo são os suspeitos de sempre.
- Ferramentas de exportação de banco de dados e de BI, que costumam emitir um BOM porque o consumidor principal delas é uma planilha.
Se o arquivo chega de um parceiro ou fornecedor e você não pode mexer no produtor, pule para a seção 4 e remova o BOM na leitura. Se ele vem do seu próprio repositório, a seção 9 resolve isso de vez.
4. Corrigindo em JavaScript e Node
É aqui que a confusão se concentra, porque o ecossistema JavaScript não tem uma política de BOM. Tem várias, e elas discordam entre si. Mesmo arquivo, mesmo runtime, medido no node v25.8.2:
| API | Comportamento com o BOM | JSON.parse em seguida |
|---|---|---|
fetch → res.json() | removido | funciona |
fs.readFileSync(f, 'utf8') | mantido | falha |
new TextDecoder() (padrão) | removido | funciona |
new TextDecoder('utf-8', { ignoreBOM: true }) | mantido | falha |
require('./data.json') | removido | n/a, já veio parseado |
import(..., { with: { type: 'json' } }) | removido | n/a, já veio parseado |
Duas coisas saem dessa tabela, e as duas já custaram tardes inteiras a muita gente.
ignoreBOM faz o contrário do que o nome sugere
ignoreBOM: true não quer dizer “ignore o BOM”. Quer dizer “ignore o significado especial do BOM e mantenha-o como um caractere qualquer”. O padrão, false, é o que remove. O nome descreve o que o decodificador ignora, não o que você recebe, e quem lê do jeito óbvio acaba com um decodificador que preserva justamente o byte que queria apagar.
Por que funciona no navegador e quebra no Node
A variante mais comum é esta: a mesma URL de JSON é parseada sem drama no código de front-end e estoura no instante em que um script Node lê o arquivo do disco. Nada mudou no arquivo. O res.json() decodifica pela mesma maquinaria do TextDecoder e descarta o BOM no caminho; já o fs.readFileSync(path, 'utf8') faz uma decodificação fiel e devolve todos os caracteres que o arquivo contém, U+FEFF incluído.
A mesma assimetria explica por que require('./config.json') funciona e JSON.parse(fs.readFileSync('./config.json', 'utf8')) não. O carregador de módulos JSON do Node remove o BOM; o caminho manual, não.
Removendo o BOM
const fs = require('fs');
const raw = fs.readFileSync('data.json', 'utf8');
const data = JSON.parse(raw.replace(/^/, ''));
Ancore o padrão com ^. Um replace global sem âncora também apagaria caracteres U+FEFF legítimos de dentro dos valores de string. Isso é perda de dados.
Existe uma alternativa mais discreta que funciona por acidente: JSON.parse(raw.trim()) também dá certo, porque o ECMAScript classifica U+FEFF como espaço em branco e o String.prototype.trim o remove. O comportamento é real e foi verificado acima, mas é uma coincidência da especificação do JavaScript e não se transfere para outras linguagens. O str.strip() do Python deixa um U+FEFF exatamente onde o encontrou.
Se quiser confirmar que o resultado sem BOM é genuinamente válido, e não apenas deixou de estourar, cole no formatador e validador JSON. Com o BOM fora do caminho, os candidatos que sobram na posição 0 são os problemas comuns de escape, cobertos no guia de escape de strings JSON.
5. Corrigindo em Python: utf-8-sig
Python é o único runtime que nomeia o problema na própria mensagem de erro. Abra um arquivo com BOM como UTF-8 puro e o json entrega o diagnóstico e a correção na mesma frase:
JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)
Se você pesquisou por unexpected utf-8 bom e caiu aqui, é dessa string que a busca veio. O codec que ela aponta lê o BOM como assinatura e o descarta:
import json
with open('data.json', encoding='utf-8-sig') as f:
data = json.load(f)
O utf-8-sig é seguro em arquivos sem BOM. Ele remove um se houver e se comporta como UTF-8 puro caso contrário, o que faz dele o padrão correto para qualquer arquivo que você não tenha produzido.
Bytes e texto se comportam de forma diferente
É essa assimetria que faz o bug parecer intermitente:
import json
json.loads(open('data.json', 'rb').read()) # {'a': 1} funciona
json.loads(open('data.json', encoding='utf-8').read()) # levanta o erro acima
O json.loads sobre bytes roda antes uma etapa de detecção de codificação, enxerga o BOM e decodifica com utf-8-sig por você. Passe uma str já decodificada e não sobra nada para detectar, então o U+FEFF chega ao parser. Dois caminhos de código que parecem equivalentes, e um deles trata o caso em silêncio.
Gravando um BOM de propósito
O mesmo codec funciona ao contrário, e é assim que se produz um arquivo para o Excel:
with open('report.csv', 'w', encoding='utf-8-sig', newline='') as f:
f.write('name\n')
Esse arquivo começa com ef bb bf. A seção 7 explica quando você quer que ele comece assim.
A armadilha do CSV
O csv.DictReader sobre um texto com BOM faz exatamente o que um parser de CSV correto deve fazer, e produz uma chave que ninguém consegue acertar:
import csv, io
data = 'name,age\nAlice,30\n'
print(list(next(csv.DictReader(io.StringIO(data))).keys()))
# ['name', 'age']
A sua primeira coluna não é name. É U+FEFF seguido de name, e toda consulta a row['name'] levanta KeyError enquanto o cabeçalho aparece certinho em qualquer debugger que você tenha. Abrir o arquivo com encoding='utf-8-sig' remove o BOM antes que o leitor chegue a vê-lo.
6. Removendo o BOM em Java, Go, PHP e no shell
Toda correção é a mesma correção em outra camada: apagar três bytes (EF BB BF) ou apagar um caractere (U+FEFF), conforme você esteja lidando com bytes ou com texto. Se a sua linguagem não tem um codec ciente de BOM, faça na mão.
Java decodifica o BOM em um caractere à frente:
String text = Files.readString(path, StandardCharsets.UTF_8);
if (!text.isEmpty() && text.charAt(0) == '') {
text = text.substring(1);
}
Go, trabalhando em nível de bytes antes do unmarshal:
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, com um padrão ancorado em bytes:
$raw = file_get_contents('data.json');
$raw = preg_replace('/^\xEF\xBB\xBF/', '', $raw);
$data = json_decode($raw, true);
Para remover o BOM de um arquivo em vez de uma variável, quatro comandos, todos testados em um arquivo que começava com ef bb bf:
# No próprio arquivo, sed do GNU (Linux). Quem expande os escapes é o shell, não o sed.
sed -i $'1s/^\xEF\xBB\xBF//' data.json
# No próprio arquivo, sed do BSD (macOS)
sed -i '' $'1s/^\xEF\xBB\xBF//' data.json
# No próprio arquivo, onde houver Perl. Somente a primeira linha.
perl -i -pe 's/^\x{ef}\x{bb}\x{bf}// if $. == 1' data.json
# Copia sem os três primeiros bytes. Só é seguro se você souber que há um BOM ali.
tail -c +4 data.json > clean.json
A forma com tail é a bruta: ela remove três bytes, sejam eles um BOM ou não. Confirme antes com a seção 2.
7. A exceção do CSV: quando o Excel precisa do BOM
Tudo acima trata o BOM como estrago. Em um lugar ele é estrutural, e apagá-lo quebra um arquivo que funcionava.
As buscas por csv bom excel se dividem em duas queixas opostas, o que é um bom indício de que uma única regra está sendo aplicada na direção errada:
- “Meu CSV abre no Excel com
éeæ¥æ¬èªno lugar dos caracteres de verdade.” Falta o BOM. - “Minha primeira coluna se chama
namee meu script não consegue encontrá-la.” O BOM está lá.
Por que o Excel quer o BOM
O Excel no Windows não tem como saber com segurança que um CSV é UTF-8. Não há cabeçalho nem declaração de codificação: um arquivo .csv é só bytes. Sem um sinal, ele recorre ao locale do sistema, ou seja, Windows-1252 nos EUA e na Europa Ocidental, Windows-1251 na Rússia, e todo caractere fora do ASCII sai errado. O BOM é esse sinal. Três bytes na frente e o Excel lê UTF-8 corretamente.
Em CSV, portanto, o BOM faz parte do formato, e a decisão cabe em uma linha:
Escrito para uma máquina parsear, remova o BOM. Escrito para uma pessoa abrir com dois cliques no Excel, mantenha.
A falha do outro lado
Entregue o mesmo arquivo a um parser e o BOM se funde à primeira célula do cabeçalho. No 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 enquanto a chave aparece como name nos seus logs, no seu debugger e no seu console.table. É o mesmo formato de bug do KeyError do Python na seção 5, e é por isso que “o nome do campo bate mas o valor não vem” merece ser tratado como sintoma de BOM já de cara.
Nossos conversores tratam os dois lados disso de propósito. O conversor CSV para JSON remove um BOM inicial da entrada antes de parsear, então um arquivo recém-saído do Excel produz name e não name. No sentido contrário, o conversor JSON para CSV transforma o BOM num interruptor explícito, e o preset do Excel o liga junto com delimitador ponto e vírgula e quebras de linha CRLF, que é a combinação de que os locales europeus do Excel realmente precisam. Para o conjunto mais amplo de decisões de conversão em torno de delimitadores, aspas e inferência de tipos, o guia de conversão CSV para JSON tem o passo a passo completo.
8. Além do JSON: onde mais um BOM aparece
O JSON reclama alto. Outros formatos, não.
Scripts de shell. O BOM fica entre o início do arquivo e o #!, então o kernel nunca vê um shebang e nunca executa o seu interpretador. No macOS, o resultado medido foi o shell cair para sh e relatar a linha do shebang como um arquivo inexistente:
./bom.sh: line 1: #!/bin/sh: No such file or directory
E aí o script rodou assim mesmo, sob o interpretador errado, o que é pior do que falhar. Outros sistemas dizem isso de outro jeito; o mais conhecido é o erro bad interpreter. Se um script que começa com um #!/usr/bin/env python3 perfeitamente correto insiste que esse caminho não existe, olhe os bytes.
PHP. Tudo o que está fora de <?php ... ?> é saída, e um BOM antes da tag de abertura são três bytes de saída enviados antes de o seu código rodar. A primeira chamada a header(), session_start() ou setcookie() falha então com o clássico aviso headers already sent, que aponta para a linha 1 de um arquivo cuja linha 1 parece vazia.
Arquivos .env e qualquer formato chave-valor. Mecanismo idêntico ao do CSV: a sua primeira variável não é DATABASE_URL, é U+FEFF seguido de DATABASE_URL, então a consulta não acha nada enquanto o arquivo parece perfeito para quem lê. Todas as variáveis seguintes funcionam, o que faz parecer um problema com uma configuração específica.
O XML é a exceção na direção contrária. A especificação do XML permite explicitamente um BOM UTF-8 no início de um documento como parte da autodetecção de codificação, e os parsers são obrigados a lidar com ele. Nos testes, o xml.etree.ElementTree do Python aceitou sem reclamar um documento com BOM. Se o XML está falhando, o motivo provavelmente não é o BOM.
9. Corte o problema na origem
Com o mecanismo entendido, tirar o BOM de um arquivo é a parte fácil. O trabalho que sobra é impedir que o arquivo ganhe outro.
Fixe a codificação no .editorconfig. A propriedade charset aceita utf-8 e utf-8-bom como valores distintos, então declarar o que você quer não deixa margem para dúvida:
[*]
charset = utf-8
Confira a configuração do editor que sobrescreve isso. No VS Code é "files.encoding": "utf8", e o valor a procurar é utf8bom. Verifique o .vscode/settings.json do workspace além das suas configurações de usuário, porque uma configuração de workspace commitada se aplica em silêncio a todo mundo do time.
Faça a varredura no CI ou num hook de pre-commit. Isto é portátil, não tem dependências e sai com código diferente de zero quando encontra algo:
#!/bin/sh
# Falha se algum arquivo rastreado começar com 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
A varredura foi testada nos dois sentidos: lista os caminhos problemáticos e sai com 1 quando há um arquivo com BOM rastreado, e sai com 0 depois que os arquivos estão limpos.
Deixe registrada a única exceção permitida. Uma regra de “nenhum BOM em lugar nenhum” é quebrada na primeira vez que alguém precisa exportar uma planilha, e depois passa a ser ignorada em geral. Declare a exceção: BOMs são permitidos em arquivos CSV gerados para o Excel, em nenhum outro lugar. Exclua o diretório de exportação da varredura e a regra sobrevive ao contato com a realidade.
10. Um fluxo de bisseção de sessenta segundos
Execute na ordem. Cada passo ou encerra a investigação ou entrega ao seguinte um problema menor.
- Leia o caractere, não a posição. Seção 1.
<significa HTML e o seu trabalho aqui acabou. Nada entre aspas significa corpo vazio. Um quadradinho ilegível significa continuar. - Confirme os bytes.
hexdump -C file | head -1. Se os três primeiros bytes não foremef bb bf, pare: não é um BOM e nada do que vem abaixo vai ajudar. - Descubra por onde ele entra. O arquivo já tem BOM em disco, ou está limpo em disco e ganha o BOM até chegar ao seu código? Um arquivo limpo em disco significa que algo no seu pipeline está adicionando.
- Escolha um lado para corrigir. Remova na leitura quando o produtor for um fornecedor, um upload ou um passo de build que não é seu. Corrija o produtor quando ele for seu, porque a correção do lado da leitura precisa ser repetida em cada leitor.
- Aplique a correção na fronteira de decodificação, não mais fundo.
encoding='utf-8-sig'na chamada deopen(), não um.lstrip()numa string três funções depois. Corrigir lá no fundo da pilha significa que o próximo caminho de código a ler o arquivo vai redescobrir o bug. - Verifique que os bytes mudaram. Repita o passo 2. Uma correção que funciona em um caminho de código e deixou o arquivo intacto vai falhar no próximo.
- Adicione a varredura. Seção 9. Caso contrário, você vai refazer tudo isso no próximo trimestre.
FAQ
O BOM UTF-8 é obrigatório?
Não. O UTF-8 tem uma única ordem de bytes, então não há nada para uma marca desambiguar. O Unicode permite um BOM UTF-8 como assinatura de codificação, mas não o recomenda, e o JSON o proíbe sem meio-termo: a RFC 8259 determina que as implementações não podem adicionar uma marca de ordem de bytes a um texto JSON.
Por que o arquivo parece normal no meu editor e mesmo assim não é parseado?
Porque U+FEFF não desenha absolutamente nada. Editores que o reconhecem escondem o caractere e mencionam UTF-8 with BOM na barra de status. Editores que não o reconhecem simplesmente pintam zero pixel. O cat, o less e um diff de code review também ficam idênticos. Só uma visão em nível de bytes o expõe.
O JSON.parse remove o BOM automaticamente em algum caso?
Nunca. O JSON.parse recebe uma string e trata U+FEFF como caractere inesperado onde quer que ele apareça. Quem remove é a camada acima: res.json() depois de um fetch, o require() do Node para arquivos .json e o TextDecoder nas configurações padrão, todos eliminam o BOM antes de o parser ver qualquer coisa.
Devo remover o BOM de arquivos CSV?
Depende de quem abre o arquivo. Qualquer parser vai embutir o BOM no nome da primeira coluna, então name vira name e toda consulta erra. Nesse caso, remova. O Excel no Windows usa o BOM para detectar UTF-8 e destrói caracteres acentuados e CJK sem ele, então nesse caso mantenha.
O BOM é a mesma coisa que um espaço de largura zero?
Mesmo code point, função diferente. U+FEFF no offset 0 é uma marca de ordem de bytes. Em qualquer outro ponto de um documento ele é ZERO WIDTH NO-BREAK SPACE, um uso que o Unicode tornou obsoleto em favor de U+2060 WORD JOINER. Textos antigos ainda o contêm, e é por isso que U+FEFF aparece no meio de arquivos.
O BOM afeta diffs do git e o tamanho do arquivo?
Três bytes em disco, e uma linha de ruído em todo diff que encosta nele. O git compara bytes, então adicionar ou remover um BOM reescreve a linha 1 mesmo quando o texto renderizado é idêntico. É daí que vem aquela mudança de uma linha que ninguém no review consegue explicar.