Skip to content
Torna al blog
Tutorial

BOM UTF-8: risolvere errori di JSON.parse e CSV

Un BOM UTF-8 fa fallire JSON.parse su un file impeccabile. Trova i byte EF BB BF, rimuovili in ogni linguaggio e scopri quando Excel li vuole. Gratis online.

14 min di lettura

BOM UTF-8: risolvere errori di JSON.parse e CSV

Dietro un errore di parsing JSON causato da un BOM UTF-8 ci sono tre byte che non puoi vedere. Il file si apre pulito nel tuo editor, cat stampa esattamente quello che ti aspetti, il linter non ha niente da ridire e JSON.parse continua a lanciare un’eccezione sul primissimo carattere.

Misurato su node v25.8.2, l’eccezione si presenta così:

SyntaxError: Unexpected token '', "{"a":1}" is not valid JSON

Qualunque cosa il tuo terminale abbia disegnato dentro quelle virgolette è un solo carattere: U+FEFF, memorizzato come i byte EF BB BF. La grammatica JSON non prevede un posto dove metterlo. Un parser alla posizione 0 si aspetta {, [, una cifra, una virgoletta o uno spazio bianco, e U+FEFF non è nessuna di queste cose.

Se sai già che si tratta di un BOM, scegli il lato che controlli:

Dove puoi intervenireLa soluzione
Node, in lettura da fileJSON.parse(raw.replace(/^/, ''))
Python, in lettura da fileopen(path, encoding='utf-8-sig')
Il file su discotail -c +4 data.json > clean.json

Il resto della pagina copre i casi in cui non è così semplice: l’errore che sembra un BOM e non lo è, la sorgente che continua a rimetterlo e l’unico formato in cui rimuoverlo è il bug invece che la soluzione. Se la tua domanda è piuttosto cos’è un BOM e se un file nuovo debba averlo, se ne occupa la guida completa alla codifica UTF-8 e UTF-16. Qui diamo per scontato che il tuo abbia già rotto qualcosa.

Tutte le misurazioni qui sotto vengono da node v25.8.2 e Python 3.14.5.

1. Cosa esclude il tuo errore prima di incolpare il BOM

La maggior parte di chi cerca un errore JSON alla posizione 0 non ha un BOM. Quattro problemi diversi producono un messaggio della stessa forma, e basta un’occhiata al carattere tra apici per distinguerli. Queste sono le stringhe letterali che emette V8:

Testo dell’erroreChe cos’è in realtàPasso successivo
Unexpected token '', "{"a":1}" is not valid JSONBOM UTF-8 al byte 0Sezione 2
Unexpected token '<', "<!DOCTYPE "... is not valid JSONLa risposta era HTML: una pagina di errore, un redirect al login, un avviso del proxyLogga il corpo grezzo e il codice di stato
Unexpected end of JSON inputIl corpo era vuotoControlla il codice di stato e Content-Length
"undefined" is not valid JSONHai passato a JSON.parse una variabile mai assegnataCorreggi il chiamante

La regola è corta abbastanza da tenerla a mente. Leggi il carattere dentro gli apici singoli. < significa che hai ricevuto HTML. Un quadratino, uno spazio vuoto o un punto interrogativo che non riesci a selezionare significa U+FEFF. Se tra gli apici non c’è proprio niente, allora non c’era nessun input da cui partire.

La vecchia formulazione e la nuova

I risultati di ricerca per json parse unexpected token position 0 sono quasi tutti scritti sulla base di un messaggio V8 più vecchio:

SyntaxError: Unexpected token in JSON at position 0

Quella formulazione indicava l’offset e nascondeva il carattere. Quella attuale fa il contrario: mostra il carattere e un frammento dell’input, che è molto più utile. Vuol dire però che la pagina su cui atterri potrebbe descrivere un runtime diverso dal tuo. Se il tuo errore nomina ancora una posizione invece di un carattere, sei su un engine più vecchio e la diagnosi qui sotto non cambia.

2. Conferma che sia un BOM in dieci secondi

Quattro controlli, più o meno in ordine di velocità. Ne basta uno qualsiasi per chiudere la questione.

Guarda i primi tre byte.

$ hexdump -C data.json | head -1
00000000  ef bb bf 7b 22 61 22 3a  31 7d                    |...{"a":1}|

ef bb bf prima del 7b ({) è il BOM. I ... nella colonna ASCII a destra sono il modo in cui hexdump ammette di non avere niente di stampabile da mostrarti.

Chiedi a file. Te lo dice in chiaro, e sui due file dà una risposta completamente diversa:

$ file data.json
data.json: Unicode text, UTF-8 (with BOM) text, with no line terminators

$ file clean.json
clean.json: JSON data

Controlla il primo code point in Node.

const fs = require('fs');
const raw = fs.readFileSync('data.json', 'utf8');
console.log(raw.charCodeAt(0) === 0xFEFF);   // true

Leggi la barra di stato dell’editor. VS Code mostra UTF-8 with BOM nell’angolo in basso a destra, e cliccandoci sopra ti propone Save with encoding. Quell’etichetta spiega da sola perché il file sembrava a posto: il tuo editor lo sapeva e non è andato oltre quella riga in fondo allo schermo.

Se il payload non puoi ispezionarlo in locale, incollalo nel codificatore e decodificatore Base64 e guardalo a livello di byte. Un BOM UTF-8 all’inizio di un payload si codifica sempre in una stringa che comincia con 77u/, ed è facile riconoscerlo dentro una riga di log.

3. Da dove arriva il tuo BOM

Rimuovere il BOM da un file che uno step di build rigenera ogni ora è una soluzione con un’ora di vita. I responsabili abituali:

  • Il Salva con nome → CSV UTF-8 di Excel. Questo è voluto, non è un bug, e la sezione 7 spiega perché.
  • Blocco note e altri editor Windows che offrono UTF-8 con BOM come opzione di salvataggio a sé stante, a volte come quella predefinita.
  • VS Code, quando files.encoding vale utf8bom, nelle tue impostazioni utente oppure committato in .vscode/settings.json, dove nessuno va a guardare.
  • La redirezione della shell in PowerShell. > e Out-File scrivono un BOM per impostazione predefinita in alcune versioni di PowerShell, e il valore predefinito cambia tra la linea 5.x solo Windows e la linea 6/7 multipiattaforma. Su questo non andare a memoria: scrivi un file e controlla i suoi primi tre byte con i comandi della sezione 2.
  • Codice di esportazione scritto a mano. Qualsiasi writer che costruisce un encoder UTF-8 senza dichiarare se emettere una firma eredita il valore predefinito del suo framework, e i framework non hanno scelto tutti la stessa cosa. I vecchi percorsi di esportazione .NET e Java sono i soliti sospetti.
  • Gli strumenti di esportazione di database e BI, che spesso mettono un BOM perché il loro consumatore principale è un foglio di calcolo.

Se il file arriva da un partner o da un fornitore e non puoi cambiare chi lo produce, salta alla sezione 4 e rimuovilo in lettura. Se arriva dal tuo repository, la sezione 9 è la risposta duratura.

4. Risolvilo in JavaScript e Node

È qui che si concentra la confusione, perché l’ecosistema JavaScript non ha una sola politica sul BOM. Ne ha diverse, e non vanno d’accordo tra loro. Stesso file, stesso runtime, misurato su node v25.8.2:

APIComportamento con il BOMJSON.parse successivo
fetchres.json()rimossoriesce
fs.readFileSync(f, 'utf8')mantenutofallisce
new TextDecoder() (predefinito)rimossoriesce
new TextDecoder('utf-8', { ignoreBOM: true })mantenutofallisce
require('./data.json')rimossonon applicabile, già analizzato
import(..., { with: { type: 'json' } })rimossonon applicabile, già analizzato

Da quella tabella escono due cose, ed entrambe fanno perdere pomeriggi interi.

ignoreBOM fa l’opposto di quello che dice

ignoreBOM: true non significa “ignora il BOM”. Significa “ignora il significato speciale del BOM e tienilo come un carattere qualsiasi”. È il valore predefinito, false, quello che lo rimuove. Il nome descrive che cosa ignora il decoder, non che cosa ottieni tu, e leggerlo nel modo naturale ti lascia con un decoder che conserva esattamente il byte che stavi cercando di cancellare.

Perché funziona nel browser e si rompe in Node

È la variante che viene segnalata più spesso: lo stesso URL JSON viene analizzato senza problemi dal codice front-end e lancia un’eccezione appena uno script Node legge il file da disco. Del file non è cambiato niente. res.json() decodifica passando dagli stessi meccanismi di TextDecoder e lascia cadere il BOM per strada; fs.readFileSync(path, 'utf8') è una decodifica fedele che ti consegna ogni carattere contenuto nel file, U+FEFF compreso.

La stessa asimmetria spiega perché require('./config.json') funziona mentre JSON.parse(fs.readFileSync('./config.json', 'utf8')) no. Il module loader JSON di Node rimuove il BOM; la strada manuale no.

Rimuovilo

const fs = require('fs');
const raw = fs.readFileSync('data.json', 'utf8');
const data = JSON.parse(raw.replace(/^/, ''));

Di nuovo l’ancoraggio ^. Una sostituzione globale senza ancoraggio cancellerebbe anche i caratteri U+FEFF legittimi dentro i valori stringa, e quella è perdita di dati, non una soluzione.

C’è un’alternativa che funziona per puro caso: anche JSON.parse(raw.trim()) riesce, perché ECMAScript classifica U+FEFF come spazio bianco e String.prototype.trim lo rimuove. È un comportamento reale, verificato qui sopra, ma è una coincidenza della specifica JavaScript e negli altri linguaggi non vale. Lo str.strip() di Python lascia un U+FEFF esattamente dove l’ha trovato.

Se vuoi confermare che il risultato ripulito sia valido davvero, e non solo che ha smesso di lanciare eccezioni, incollalo nel formattatore e validatore JSON. Una volta sparito il BOM, i candidati rimasti alla posizione 0 sono i normali problemi di escape trattati nella guida all’escape delle stringhe JSON.

5. Risolvilo in Python: utf-8-sig

Python è l’unico runtime che nomina il problema nel messaggio di errore. Apri come UTF-8 semplice un file con il BOM davanti e json ti dice il problema e la soluzione nella stessa frase:

JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)

Se hai cercato unexpected utf-8 bom e sei atterrato qui, è da quella stringa che arrivi. Il codec a cui punta legge il BOM come una firma e lo scarta:

import json

with open('data.json', encoding='utf-8-sig') as f:
    data = json.load(f)

utf-8-sig è sicuro anche sui file senza BOM. Ne rimuove uno se c’è e per il resto si comporta come UTF-8 semplice, il che ne fa il valore predefinito corretto per qualsiasi file che non hai prodotto tu.

Byte e testo si comportano in modo diverso

Un’asimmetria che spiega perché il bug sembra intermittente:

import json

json.loads(open('data.json', 'rb').read())       # {'a': 1}      funziona
json.loads(open('data.json', encoding='utf-8').read())  # solleva l'errore qui sopra

json.loads su bytes esegue prima un passaggio di rilevamento della codifica, individua il BOM e decodifica con utf-8-sig al posto tuo. Dagli una str già decodificata e non resta più niente da rilevare, così U+FEFF arriva fino al parser. Due percorsi di codice all’apparenza equivalenti, uno dei quali gestisce il caso in silenzio.

Scrivi un BOM di proposito

Lo stesso codec funziona anche al contrario, ed è così che produci un file per Excel:

with open('report.csv', 'w', encoding='utf-8-sig', newline='') as f:
    f.write('name\n')

Quel file comincia con ef bb bf. La sezione 7 spiega quando è proprio quello che vuoi.

La trappola CSV

csv.DictReader su un testo con il BOM davanti fa esattamente quello che un parser CSV corretto deve fare, e produce una chiave che nessuno riesce a far combaciare:

import csv, io

data = 'name,age\nAlice,30\n'
print(list(next(csv.DictReader(io.StringIO(data))).keys()))
# ['name', 'age']

La tua prima colonna non è name. È U+FEFF seguito da name, e ogni accesso row['name'] solleva KeyError mentre l’intestazione si stampa correttamente in qualsiasi debugger tu abbia. Aprire il file con encoding='utf-8-sig' lo rimuove prima ancora che il reader lo veda.

6. Rimuovi il BOM in Java, Go, PHP e nella shell

Ogni soluzione è la stessa soluzione a un’altezza diversa: cancella tre byte (EF BB BF) oppure cancella un carattere (U+FEFF), a seconda che tu abbia in mano byte o testo. Se il tuo linguaggio non ha un codec consapevole del BOM, fallo a mano.

Java decodifica il BOM in un carattere  iniziale:

String text = Files.readString(path, StandardCharsets.UTF_8);
if (!text.isEmpty() && text.charAt(0) == '') {
    text = text.substring(1);
}

Go, lavorando a livello di byte prima dell’unmarshalling:

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, con un pattern ancorato ai byte:

$raw  = file_get_contents('data.json');
$raw  = preg_replace('/^\xEF\xBB\xBF/', '', $raw);
$data = json_decode($raw, true);

Per rimuovere il BOM da un file invece che da una variabile, quattro comandi tutti verificati su un file che comincia con ef bb bf:

# Sul posto, GNU sed (Linux). Gli escape li espande la shell, non sed.
sed -i $'1s/^\xEF\xBB\xBF//' data.json

# Sul posto, BSD sed (macOS)
sed -i '' $'1s/^\xEF\xBB\xBF//' data.json

# Sul posto, ovunque esista Perl. Solo la prima riga.
perl -i -pe 's/^\x{ef}\x{bb}\x{bf}// if $. == 1' data.json

# Copia senza i primi tre byte. Sicuro solo se sai già che il BOM c'è.
tail -c +4 data.json > clean.json

La forma con tail è quella grezza: rimuove tre byte, che siano un BOM oppure no. Prima conferma con la sezione 2.

7. L’eccezione CSV: quando Excel ha bisogno del BOM

Tutto quello che precede tratta il BOM come un danno. In un caso invece serve, e cancellarlo rompe un file che funzionava.

Le ricerche per csv bom excel si dividono in due lamentele opposte, il segno che dietro c’è un’unica regola applicata nella direzione sbagliata:

  1. “Il mio CSV si apre in Excel con é e æ¥æ¬èª al posto dei caratteri veri.” Manca il BOM.
  2. “La mia prima colonna si chiama name e il mio script non riesce a trovarla.” Il BOM c’è.

Perché Excel lo vuole

Excel su Windows non ha un modo affidabile per sapere che un CSV è UTF-8. Non c’è un header e non ci sono metadati: un file .csv è byte e basta. Senza un segnale ripiega sul locale di sistema, quindi Windows-1252 negli Stati Uniti e in Europa occidentale, Windows-1251 in Russia, e ogni carattere non-ASCII esce sbagliato. Il BOM è quel segnale. Tre byte davanti ed Excel legge UTF-8 correttamente.

Questo fa del BOM nei CSV una funzionalità e non un difetto, e la decisione che ne deriva sta in una riga sola:

Se il file lo deve analizzare una macchina, rimuovi il BOM. Se lo deve aprire una persona con un doppio clic in Excel, tienilo.

Il problema dal lato opposto

Dai in pasto lo stesso file a un parser e il BOM si fonde con la tua prima cella di intestazione. In 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 mentre la chiave si stampa come name nei tuoi log, nel debugger e nel tuo console.table. È lo stesso identico bug del KeyError di Python nella sezione 5, ed è il motivo per cui conviene leggere “il nome del campo combacia ma il valore manca” come un sintomo da BOM.

I nostri due convertitori applicano la regola nei due sensi opposti. Il convertitore da CSV a JSON rimuove un BOM iniziale dall’input prima di analizzarlo, così un file uscito dritto da Excel produce name e non name. Nella direzione opposta, il convertitore da JSON a CSV rende il BOM un’opzione esplicita, e il suo preset Excel lo attiva insieme al punto e virgola come delimitatore e ai fine riga CRLF, che è la combinazione di cui hanno davvero bisogno i locale Excel europei. Le altre decisioni sulla conversione, dai delimitatori alle virgolette all’inferenza dei tipi, le tratta per esteso la guida alla conversione tra CSV e JSON.

8. Oltre il JSON: dove altro compare un BOM

Il JSON lo urla. Altri formati no.

Script di shell. Un BOM si mette tra l’inizio del file e il #!, quindi il kernel non vede mai uno shebang e non esegue mai il tuo interprete. Su macOS, nella prova, la shell è ripiegata su sh e ha segnalato la riga dello shebang come un file mancante:

./bom.sh: line 1: #!/bin/sh: No such file or directory

Lo script è poi stato eseguito lo stesso, con l’interprete sbagliato, che è peggio di un errore secco. Altri sistemi lo formulano in modo diverso, nel caso più famoso come un errore bad interpreter. Se uno script che comincia con un #!/usr/bin/env python3 perfettamente corretto insiste che quel percorso non esiste, controlla i byte.

PHP. Tutto quello che sta fuori da <?php ... ?> è output, e un BOM prima del tag di apertura sono tre byte di output inviati prima che il tuo codice parta. La prima chiamata a header(), session_start() o setcookie() fallisce poi con il classico avviso headers already sent, che punta alla riga 1 di un file la cui riga 1 sembra vuota.

I file .env e qualsiasi formato chiave-valore. Meccanismo identico al caso CSV: la tua prima variabile non è DATABASE_URL, è U+FEFF seguito da DATABASE_URL, quindi la ricerca fallisce mentre a una persona il file si legge correttamente. Tutte le variabili successive funzionano, e questo fa sembrare che il problema riguardi una singola impostazione.

L’XML è l’eccezione nella direzione opposta. La specifica XML permette esplicitamente un BOM UTF-8 all’inizio di un documento, come parte del rilevamento automatico della codifica, e i parser sono tenuti a gestirlo. Nei test, xml.etree.ElementTree di Python ha accettato senza protestare un documento con il BOM davanti. Se l’XML sta fallendo, il BOM probabilmente non c’entra.

9. Fermalo alla fonte

Una volta capito il meccanismo, togliere il BOM da un file è la parte facile. La parte che dura è impedire al file di riprenderselo.

Fissa la codifica in .editorconfig. La proprietà charset accetta utf-8 e utf-8-bom come valori distinti, quindi dichiarare quello che vuoi non lascia ambiguità:

[*]
charset = utf-8

Controlla l’impostazione dell’editor che la scavalca. In VS Code è "files.encoding": "utf8", e il valore da cercare è utf8bom. Guarda il .vscode/settings.json del workspace oltre alle tue impostazioni utente, perché un’impostazione di workspace committata si applica in silenzio a tutta la squadra.

Fai una scansione in CI o in un hook pre-commit. Lo script qui sotto è portabile, non ha dipendenze ed esce con codice diverso da zero quando trova qualcosa:

#!/bin/sh
# Fallisce se un file tracciato comincia con 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

Verificato in entrambi i sensi: elenca i percorsi incriminati ed esce con 1 quando trova un file tracciato con il BOM davanti, con 0 quando i file sono puliti.

Metti per iscritto l’unica eccezione ammessa. Una regola del tipo “niente BOM da nessuna parte” viene violata la prima volta che a qualcuno serve un’esportazione per il foglio di calcolo, e da lì in poi smette di valere del tutto. Dichiara invece l’eccezione: i BOM sono ammessi nei file CSV generati per Excel, da nessun’altra parte. Escludi la directory delle esportazioni dallo scanner e la regola sopravvive all’impatto con la realtà.

10. Un flusso di bisezione da sessanta secondi

Esegui i passi in ordine. Ogni passo o chiude l’indagine o consegna al successivo un problema più piccolo.

  1. Leggi il carattere, non la posizione. Sezione 1. < significa HTML e qui hai finito. Niente tra apici significa corpo vuoto. Un quadratino illeggibile significa continua.
  2. Conferma i byte. hexdump -C file | head -1. Se i primi tre byte non sono ef bb bf, fermati: non è un BOM e niente di quello che segue ti aiuterà.
  3. Trova da dove entra. Il file ha il BOM davanti già su disco, oppure su disco è pulito e ce l’ha solo quando arriva al tuo codice? Un file pulito su disco significa che qualcosa nella tua pipeline glielo sta aggiungendo.
  4. Scegli un solo lato da correggere. Rimuovilo in lettura quando a produrlo è un fornitore, un upload o uno step di build che non è tuo. Correggi il produttore quando è tuo, perché la correzione in lettura va ripetuta a ogni lettore.
  5. Applica la correzione al confine di decodifica, non più in profondità. encoding='utf-8-sig' sulla chiamata a open(), non un .lstrip() su una stringa tre funzioni più avanti. Correggerlo in fondo allo stack significa che il prossimo percorso di codice che legge il file si riscopre il bug da capo.
  6. Verifica che i byte siano cambiati. Ripeti il passo 2. Una correzione che funziona in un percorso di codice ma ha lasciato il file intatto fallirà nel percorso successivo.
  7. Aggiungi lo scanner. Sezione 9. Altrimenti rifarai tutto questo il prossimo trimestre.

FAQ

Il BOM UTF-8 è obbligatorio?

No. UTF-8 ha un solo ordine dei byte, quindi non c’è niente che un marcatore debba disambiguare. Unicode permette un BOM UTF-8 come firma della codifica ma non lo raccomanda, e JSON lo vieta senza mezzi termini: l’RFC 8259 stabilisce che le implementazioni non devono aggiungere un byte order mark a un testo JSON.

Perché il file sembra a posto nel mio editor ma non viene analizzato?

Perché U+FEFF non disegna proprio niente. Gli editor che lo riconoscono nascondono il carattere e in cambio segnalano UTF-8 with BOM nella barra di stato. Quelli che non lo riconoscono disegnano semplicemente zero pixel. Anche cat, less e il diff di una code review sembrano identici. Solo una vista a livello di byte lo mette allo scoperto.

JSON.parse rimuove mai il BOM automaticamente?

Mai. JSON.parse prende una stringa e tratta U+FEFF come un carattere inatteso ovunque compaia. A rimuoverlo è lo strato sopra: res.json() dopo una fetch, il require() di Node per i file .json e TextDecoder con le impostazioni predefinite lo tolgono tutti prima che il parser veda qualsiasi cosa.

Devo rimuovere il BOM dai file CSV?

Dipende da chi apre il file. Qualsiasi parser ingloberà il BOM nel nome della tua prima colonna, quindi name diventa name e ogni ricerca fallisce. Lì rimuovilo. Excel su Windows usa il BOM per rilevare UTF-8 e senza di esso storpia i caratteri accentati e quelli CJK, quindi lì tienilo.

Il BOM è la stessa cosa di uno spazio a larghezza zero?

Stesso code point, lavoro diverso. U+FEFF all’offset 0 è un byte order mark. In qualsiasi altro punto di un documento è ZERO WIDTH NO-BREAK SPACE, un uso che Unicode ha deprecato a favore di U+2060 WORD JOINER. I testi vecchi lo contengono ancora, ed è per questo che U+FEFF salta fuori in mezzo ai file.

Il BOM influisce sui diff git e sulla dimensione del file?

Tre byte su disco, e una riga rumorosa in ogni diff che lo tocca. Git confronta byte, quindi aggiungere o togliere un BOM riscrive la riga 1 anche quando il testo renderizzato è identico. È da qui che arriva quella modifica di una riga che in review nessuno riesce a spiegare.

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

Articoli correlati

Vedi tutti gli articoli