Skip to content
Voltar ao blog
Tutoriais

BOM UTF-8: corrigir erros de JSON.parse e CSV no Excel

Um BOM UTF-8 quebra o JSON.parse num arquivo que parece perfeito. Encontre os bytes invisíveis EF BB BF, remova-os em qualquer linguagem e saiba quando o Excel precisa deles.

14 min de leitura

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 mexerA correção
Node, lendo um arquivoJSON.parse(raw.replace(/^/, ''))
Python, lendo um arquivoopen(path, encoding='utf-8-sig')
O arquivo em discotail -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 erroO que é de fatoPróximo passo
Unexpected token '', "{"a":1}" is not valid JSONBOM UTF-8 no byte 0Seção 2
Unexpected token '<', "<!DOCTYPE "... is not valid JSONA resposta era HTML: uma página de erro, um redirecionamento de login, um aviso de proxyRegistre o corpo bruto e o código de status
Unexpected end of JSON inputO corpo estava vazioConfira o código de status e o Content-Length
"undefined" is not valid JSONVocê entregou ao JSON.parse uma variável que nunca foi atribuídaCorrija 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.encoding está em utf8bom, 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 o Out-File gravam 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:

APIComportamento com o BOMJSON.parse em seguida
fetchres.json()removidofunciona
fs.readFileSync(f, 'utf8')mantidofalha
new TextDecoder() (padrão)removidofunciona
new TextDecoder('utf-8', { ignoreBOM: true })mantidofalha
require('./data.json')removidon/a, já veio parseado
import(..., { with: { type: 'json' } })removidon/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:

  1. “Meu CSV abre no Excel com é e æ¥æ¬èª no lugar dos caracteres de verdade.” Falta o BOM.
  2. “Minha primeira coluna se chama name e 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.

  1. 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.
  2. Confirme os bytes. hexdump -C file | head -1. Se os três primeiros bytes não forem ef bb bf, pare: não é um BOM e nada do que vem abaixo vai ajudar.
  3. 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.
  4. 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.
  5. Aplique a correção na fronteira de decodificação, não mais fundo. encoding='utf-8-sig' na chamada de open(), 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.
  6. 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.
  7. 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.

Tags: utf-8 bom json csv debugging character-encoding

Artigos relacionados

Ver todos os artigos