Skip to content
Volver al blog
Tutoriales

BOM UTF-8: corregir errores de JSON.parse y CSV

Un BOM UTF-8 rompe JSON.parse en un archivo que parece perfecto. Detecta los bytes invisibles EF BB BF, elimínalos en cualquier lenguaje y descubre cuándo Excel los necesita.

14 min de lectura

BOM UTF-8: corregir errores de JSON.parse y CSV

Un error de análisis de JSON provocado por un BOM UTF-8 son tres bytes que no puedes ver. El archivo se abre limpio en tu editor, cat imprime exactamente lo que esperas, tu linter no se queja y JSON.parse sigue lanzando una excepción en el primerísimo carácter.

Medido en node v25.8.2, la excepción se ve así:

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

Sea lo que sea que tu terminal haya dibujado dentro de esas comillas, es un solo carácter: U+FEFF, almacenado como los bytes EF BB BF. El JSON estricto no tiene ninguna ranura para él. Un parser en la posición 0 espera {, [, un dígito, una comilla o un espacio en blanco, y U+FEFF no es nada de eso.

Si ya sabes que es un BOM, elige el lado que sí controlas:

Dónde puedes cambiar algoLa solución
Node, al leer un archivoJSON.parse(raw.replace(/^/, ''))
Python, al leer un archivoopen(path, encoding='utf-8-sig')
El archivo en discotail -c +4 data.json > clean.json

El resto de esta página es para cuando eso no alcanza: el error que parece un BOM y no lo es, el origen que vuelve a ponerlo una y otra vez, y el único formato donde quitarlo es el error y no la solución. Si lo que buscas es qué es un BOM y si un archivo nuevo debería llevarlo, la guía completa de codificación UTF-8 vs UTF-16 cubre ese terreno. Esta página da por hecho que el tuyo ya rompió algo.

Todo lo que se midió aquí abajo se ejecutó en node v25.8.2 y Python 3.14.5.

1. Lo que tu error descarta antes de que culpes al BOM

La mayoría de quienes buscan un error de JSON en la posición 0 no tienen un BOM. Cuatro problemas distintos producen un mensaje con la misma forma, y basta una mirada al carácter entre comillas para separarlos. Estas son las cadenas literales que emite V8:

Texto del errorQué es en realidadSiguiente paso
Unexpected token '', "{"a":1}" is not valid JSONUn BOM UTF-8 en el byte 0Sección 2
Unexpected token '<', "<!DOCTYPE "... is not valid JSONLa respuesta era HTML: una página de error, una redirección al inicio de sesión, un aviso del proxyRegistra el cuerpo crudo y el código de estado
Unexpected end of JSON inputEl cuerpo venía vacíoRevisa el código de estado y Content-Length
"undefined" is not valid JSONLe pasaste a JSON.parse una variable que nunca se asignóCorrige a quien la llama

La regla es lo bastante corta como para memorizarla. Lee el carácter que está entre comillas simples. < significa que recibiste HTML. Un recuadro, un hueco o un signo de interrogación que no puedes seleccionar significa U+FEFF. Que no haya nada entre comillas significa que nunca hubo entrada.

El mensaje antiguo y el mensaje nuevo

Casi todos los resultados de búsqueda para json parse unexpected token position 0 se escribieron para un mensaje más viejo de V8:

SyntaxError: Unexpected token in JSON at position 0

Esa redacción nombraba el desplazamiento y escondía el carácter. La redacción actual hace justo lo contrario: muestra el carácter y un fragmento de la entrada, lo cual es mucho más útil, pero implica que la página en la que aterrizas quizá esté describiendo un entorno de ejecución que tú no usas. Si tu error todavía nombra una posición en lugar de un carácter, estás en un motor más viejo y el diagnóstico de abajo no cambia.

2. Confirma que es un BOM en diez segundos

Cuatro comprobaciones, más o menos ordenadas por rapidez. Cualquiera de ellas zanja el asunto.

Mira los primeros tres bytes.

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

ef bb bf antes del 7b ({) es el BOM. Los ... de la columna ASCII de la derecha son la manera que tiene hexdump de decir que ahí no hay nada imprimible.

Pregúntale a file. Lo dice directamente, y además cambia por completo de opinión sobre el tipo de archivo:

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

$ file clean.json
clean.json: JSON data

Comprueba el primer punto de código en Node.

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

Lee la barra de estado del editor. VS Code muestra UTF-8 with BOM en la esquina inferior derecha, y al hacer clic ahí ofrece Save with encoding (guardar con codificación). Esa etiqueta es toda la razón por la que el archivo parecía correcto: tu editor ya lo sabía y lo dijo en letra pequeña.

Para ver a nivel de bytes algo que no puedes volcar en local, pégalo en el codificador y decodificador Base64 online. Un BOM UTF-8 al principio de una carga útil siempre se codifica como una cadena que empieza por 77u/, algo que conviene reconocer al vuelo en una línea de log.

3. De dónde salió tu BOM

Quitarle el BOM a un archivo que un paso de compilación regenera cada hora es una solución con una hora de vida útil. Los productores habituales:

  • El Guardar como → CSV UTF-8 de Excel. Este es deliberado, no es un fallo, y la sección 7 explica por qué.
  • El Bloc de notas y otros editores de Windows que ofrecen UTF-8 con BOM como una opción de guardado aparte, a veces como la predeterminada.
  • VS Code, cuando files.encoding está puesto en utf8bom, ya sea en tu configuración de usuario o commiteado en .vscode/settings.json, donde nadie mira.
  • La redirección de shell en PowerShell. > y Out-File escriben un BOM de forma predeterminada en algunas versiones de PowerShell, y ese valor predeterminado difiere entre la línea 5.x, solo para Windows, y la línea 6/7 multiplataforma. No te fíes de la memoria aquí: escribe un archivo y revisa sus primeros tres bytes con los comandos de la sección 2.
  • Código de exportación hecho a mano. Cualquier escritor que construya un codificador UTF-8 sin declarar si debe emitir una firma hereda lo que su framework haya elegido como predeterminado, y ahí no todos eligieron lo mismo. Las rutas de exportación de .NET antiguo y de Java antiguo son las sospechosas de siempre.
  • Las herramientas de exportación de bases de datos y de BI, que suelen incluir un BOM porque su consumidor principal es una hoja de cálculo.

Si el archivo llega de un socio o de un proveedor y no puedes cambiar el productor, salta a la sección 4 y quítalo al leer. Si viene de tu propio repositorio, la sección 9 es la respuesta duradera.

4. Cómo corregirlo en JavaScript y Node

Aquí es donde se concentra la confusión, porque el ecosistema de JavaScript no tiene una sola política sobre el BOM. Tiene varias, y no se ponen de acuerdo. Mismo archivo, mismo entorno de ejecución, medido en node v25.8.2:

APIComportamiento con el BOMJSON.parse posterior
fetchres.json()lo quitafunciona
fs.readFileSync(f, 'utf8')lo conservafalla
new TextDecoder() (predeterminado)lo quitafunciona
new TextDecoder('utf-8', { ignoreBOM: true })lo conservafalla
require('./data.json')lo quitan/a, ya está analizado
import(..., { with: { type: 'json' } })lo quitan/a, ya está analizado

De esa tabla se desprenden dos cosas, y las dos le cuestan tardes enteras a la gente.

ignoreBOM hace lo contrario de lo que dice

ignoreBOM: true no significa «ignora el BOM». Significa «ignora el significado especial del BOM y consérvalo como un carácter cualquiera». El valor predeterminado, false, es el que lo elimina. El nombre describe lo que ignora el decodificador, no lo que tú recibes, y leerlo de la manera natural te deja con un decodificador que preserva exactamente el byte que querías borrar.

Por qué funciona en el navegador y falla en Node

Esta es la versión del problema que más se reporta: la misma URL de JSON se analiza sin problemas en el código de front-end y lanza una excepción en cuanto un script de Node lee el archivo del disco. Nada cambió en el archivo. res.json() decodifica con la misma maquinaria que TextDecoder y descarta el BOM por el camino; fs.readFileSync(path, 'utf8') es una decodificación fiel que te entrega todos los caracteres que el archivo contiene, U+FEFF incluido.

La misma asimetría explica por qué require('./config.json') funciona mientras que JSON.parse(fs.readFileSync('./config.json', 'utf8')) no. El cargador de módulos JSON de Node quita el BOM; la ruta manual no.

Cómo quitarlo

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

Ancla el patrón con ^. Un reemplazo global sin anclar también borraría caracteres U+FEFF legítimos que estén dentro de los valores de cadena, y eso es pérdida de datos, no una solución.

Una alternativa más discreta que funciona por accidente: JSON.parse(raw.trim()) también tiene éxito, porque ECMAScript clasifica U+FEFF como espacio en blanco y String.prototype.trim lo elimina. Es un comportamiento real, verificado más arriba, pero es una casualidad de la especificación de JavaScript y no se traslada a otros lenguajes. El str.strip() de Python deja el U+FEFF exactamente donde lo encontró.

Si quieres confirmar que el resultado ya sin BOM es genuinamente válido y no solo que dejó de lanzar excepciones, pégalo en el formateador y validador JSON online. Una vez fuera el BOM, los candidatos que quedan en la posición 0 son los problemas de escapado corrientes que cubre la guía de escapado de cadenas JSON.

5. Cómo corregirlo en Python: utf-8-sig

Python es el único entorno de ejecución que nombra el problema en el propio mensaje de error. Abre un archivo con BOM al principio como UTF-8 a secas y json te da el diagnóstico y la solución de una sola vez:

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

Si buscaste unexpected utf-8 bom y aterrizaste aquí, de ahí sale esa cadena. El códec al que apunta lee el BOM como una firma y lo descarta:

import json

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

utf-8-sig es seguro en archivos que no tienen BOM. Quita uno si está presente y, si no, se comporta como UTF-8 a secas. Es el valor predeterminado correcto para cualquier archivo que no hayas producido tú.

Los bytes y el texto se comportan distinto

Conviene conocer esta asimetría, porque hace que el error parezca intermitente:

import json

json.loads(open('data.json', 'rb').read())       # {'a': 1}      funciona
json.loads(open('data.json', encoding='utf-8').read())  # lanza el error de arriba

json.loads sobre bytes corre primero un paso de detección de codificación, ve el BOM y decodifica con utf-8-sig por ti. Dale un str ya decodificado y no queda nada que detectar, así que el U+FEFF llega hasta el parser. Dos rutas de código que parecen equivalentes, y solo una de ellas maneja el caso en silencio.

Escribir un BOM a propósito

El mismo códec funciona a la inversa, y así es como produces un archivo para Excel:

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

Ese archivo empieza por ef bb bf. La sección 7 explica cuándo te conviene que sea así.

La trampa del CSV

csv.DictReader sobre texto con BOM al principio hace exactamente lo que debe hacer un parser de CSV correcto, y produce una clave que nadie puede acertar:

import csv, io

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

Tu primera columna no es name. Es U+FEFF seguido de name, y cada búsqueda row['name'] lanza KeyError mientras el encabezado se imprime correctamente en todos los depuradores que tengas. Abrir el archivo con encoding='utf-8-sig' lo quita antes de que el lector siquiera lo vea.

6. Cómo quitar el BOM en Java, Go, PHP y la shell

Todas las soluciones son la misma solución a distinta altura: borrar tres bytes (EF BB BF) o borrar un carácter (U+FEFF), según si tienes bytes o texto en la mano. Si tu lenguaje no trae un códec que entienda el BOM, hazlo a mano.

Java decodifica el BOM como un carácter  inicial:

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

Go, trabajando a nivel de bytes antes de deserializar:

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 patrón anclado a bytes:

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

Para quitarle el BOM a un archivo en lugar de a una variable, cuatro comandos, todos probados sobre un archivo que empieza por ef bb bf:

# En el sitio, GNU sed (Linux). Las secuencias de escape las expande la shell, no sed.
sed -i $'1s/^\xEF\xBB\xBF//' data.json

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

# En el sitio, en cualquier parte donde exista Perl. Solo la primera línea.
perl -i -pe 's/^\x{ef}\x{bb}\x{bf}// if $. == 1' data.json

# Copia sin los primeros tres bytes. Solo es seguro si sabes que hay un BOM.
tail -c +4 data.json > clean.json

La forma con tail es la bruta: quita tres bytes, hayan sido o no un BOM. Confírmalo primero con la sección 2.

7. La excepción del CSV: cuándo Excel necesita que el BOM se quede

Todo lo anterior trata al BOM como un estorbo. Hay un solo lugar donde es estructural, y borrarlo rompe un archivo que funcionaba.

Las búsquedas de csv bom excel se parten en dos quejas opuestas, buena señal de que se está aplicando una única regla en la dirección equivocada:

  1. «Mi CSV se abre en Excel con é y æ¥æ¬èª en vez de caracteres de verdad.» Falta el BOM.
  2. «Mi primera columna se llama name y mi script no la encuentra.» El BOM está presente.

Por qué Excel lo quiere

Excel en Windows no tiene forma fiable de saber que un CSV es UTF-8. No hay cabecera, ni declaración, ni metadatos: un archivo .csv son bytes. Sin una señal, recurre a la configuración regional del sistema, o sea Windows-1252 en Estados Unidos y Europa Occidental, Windows-1251 en Rusia, y todos los caracteres que no son ASCII salen mal. El BOM es esa señal. Tres bytes al principio y Excel lee UTF-8 correctamente.

Eso convierte al BOM del CSV en una característica y no en un defecto, y produce una decisión que cabe en una línea:

Si está escrito para que lo analice una máquina, quita el BOM. Si está escrito para que una persona lo abra con doble clic en Excel, déjalo.

El fallo del otro lado

Dale ese mismo archivo a un parser y el BOM se funde con la primera celda del encabezado. En 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 es undefined mientras la clave se imprime como name en tus logs, en tu depurador y en tu console.table. Es el mismo tipo de error que el KeyError de Python de la sección 5, y por eso conviene tratar «el nombre del campo coincide pero falta el valor» como síntoma de BOM apenas lo veas.

Nuestros propios conversores cubren los dos lados a propósito. El convertidor de CSV a JSON online quita un BOM inicial de la entrada antes de analizarla, así que un archivo recién salido de Excel produce name y no name. En el sentido contrario, el convertidor de JSON a CSV online convierte el BOM en una opción explícita, y su preajuste para Excel la activa junto con un delimitador de punto y coma y saltos de línea CRLF, que es la combinación que de verdad necesitan las configuraciones regionales europeas de Excel. Para el conjunto más amplio de decisiones de conversión sobre delimitadores, comillas e inferencia de tipos, la guía de conversión de CSV a JSON tiene el recorrido completo.

8. Más allá de JSON: en qué otros sitios aparece un BOM

JSON es ruidoso al respecto. Otros formatos no.

Scripts de shell. Un BOM se mete entre el inicio del archivo y el #!, así que el kernel nunca ve un shebang y nunca ejecuta tu intérprete. En macOS el resultado medido fue que la shell recurrió a sh y reportó la línea del shebang como un archivo inexistente:

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

Y luego el script se ejecutó igual con el intérprete equivocado, que es peor que fallar. Otros sistemas lo redactan distinto, sobre todo con el famoso error bad interpreter. Si un script que empieza con un #!/usr/bin/env python3 perfectamente correcto insiste en que esa ruta no existe, revisa los bytes.

PHP. Todo lo que queda fuera de <?php ... ?> es salida, y un BOM delante de la etiqueta de apertura son tres bytes de salida enviados antes de que corra tu código. La primera llamada a header(), session_start() o setcookie() falla entonces con el clásico aviso headers already sent, y apunta a la línea 1 de un archivo cuya línea 1 parece vacía.

Los archivos .env y cualquier formato clave-valor. Mecanismo idéntico al del CSV: tu primera variable no es DATABASE_URL, es U+FEFF seguido de DATABASE_URL, así que la búsqueda falla mientras el archivo se lee correctamente para una persona. Todas las variables siguientes funcionan, así que parece un problema de un ajuste concreto.

XML es la excepción en el sentido contrario. La especificación de XML permite explícitamente un BOM UTF-8 al inicio de un documento como parte de la autodetección de la codificación, y los parsers están obligados a lidiar con él. El xml.etree.ElementTree de Python aceptó sin quejarse un documento con BOM al principio durante las pruebas. Si XML está fallando, lo más probable es que el BOM no sea la razón.

9. Deténlo en el origen

Una vez que entiendes el mecanismo, queda el trabajo que dura: evitar que el archivo vuelva a tener un BOM.

Fija la codificación en .editorconfig. La propiedad charset acepta utf-8 y utf-8-bom como valores distintos, así que declarar el que quieres no deja lugar a ambigüedades:

[*]
charset = utf-8

Revisa el ajuste del editor que la anula. En VS Code es "files.encoding": "utf8", y el valor que hay que buscar es utf8bom. Mira el .vscode/settings.json del espacio de trabajo además de tu configuración de usuario, porque un ajuste de espacio de trabajo commiteado se le aplica en silencio a todo el equipo.

Escanea en CI o en un hook de pre-commit. Esto es portable, no tiene dependencias y sale con un código distinto de cero cuando encuentra algo:

#!/bin/sh
# Falla si algún archivo versionado empieza por 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

Verificado en ambos sentidos: lista las rutas culpables y sale con 1 cuando hay un archivo con BOM versionado, y sale con 0 en cuanto los archivos están limpios.

Escribe cuál es la única excepción permitida. Una regla de «nada de BOM en ninguna parte» se rompe la primera vez que alguien necesita exportar una hoja de cálculo, y a partir de ahí deja de aplicarse. Mejor declara la excepción: se permiten BOM en los archivos CSV generados para Excel, en ningún otro sitio. Excluye del escáner el directorio de exportación y la regla sobrevive al contacto con la realidad.

10. Un flujo de bisección de sesenta segundos

Ejecútalo en orden. Cada paso o bien termina la investigación o bien le entrega al siguiente un problema más pequeño.

  1. Lee el carácter, no la posición. Sección 1. < significa HTML y aquí terminaste. Que no haya nada entre comillas significa cuerpo vacío. Un recuadro ilegible significa que hay que seguir.
  2. Confirma los bytes. hexdump -C file | head -1. Si los primeros tres bytes no son ef bb bf, detente: esto no es un BOM y nada de lo que sigue te va a servir.
  3. Averigua por dónde entra. ¿El archivo ya trae el BOM en disco, o está limpio en disco y lo trae para cuando tu código lo tiene en la mano? Un archivo limpio en disco significa que algo de tu canalización se lo está agregando.
  4. Elige un solo lado que corregir. Quítalo al leer cuando el productor es un proveedor, una carga de archivos o un paso de compilación que no controlas. Corrige el productor cuando es tuyo, porque la solución del lado de la lectura hay que repetirla en cada lector.
  5. Aplica la corrección en el límite de decodificación, no más adentro. encoding='utf-8-sig' en la llamada a open(), no un .lstrip() sobre una cadena tres funciones después. Corregirlo en el fondo de la pila significa que la siguiente ruta de código que lea el archivo va a redescubrir el error.
  6. Verifica que los bytes cambiaron. Repite el paso 2. Una solución que funciona en una ruta de código pero dejó el archivo intacto va a fallar en la siguiente.
  7. Agrega el escáner. Sección 9. Si no, vas a hacer todo esto otra vez el próximo trimestre.

Preguntas frecuentes

¿El BOM UTF-8 es obligatorio?

No. UTF-8 tiene un único orden de bytes, así que no hay nada que una marca deba desambiguar. Unicode permite un BOM UTF-8 como firma de codificación pero no lo recomienda, y JSON lo prohíbe de plano: el RFC 8259 establece que las implementaciones no deben agregar una marca de orden de bytes a un texto JSON.

¿Por qué el archivo se ve bien en mi editor pero falla al analizarlo?

Porque U+FEFF no dibuja absolutamente nada. Los editores que lo reconocen esconden el carácter y a cambio mencionan UTF-8 with BOM en la barra de estado. Los que no lo reconocen simplemente pintan cero píxeles. cat, less y el diff de una revisión de código también se ven idénticos. Solo una vista a nivel de bytes lo deja al descubierto.

¿JSON.parse quita el BOM automáticamente alguna vez?

Nunca. JSON.parse recibe una cadena y trata U+FEFF como un carácter inesperado dondequiera que aparezca. Lo que sí lo quita es la capa de arriba: res.json() después de un fetch, el require() de Node para archivos .json y TextDecoder con su configuración predeterminada lo eliminan antes de que el parser vea nada.

¿Debo quitarle el BOM a los archivos CSV?

Depende de quién abra el archivo. Cualquier parser va a fundir el BOM con el nombre de tu primera columna, así que name se vuelve name y todas las búsquedas fallan. Ahí, quítalo. Excel en Windows usa el BOM para detectar UTF-8 y sin él destroza los caracteres acentuados y los CJK, así que ahí, déjalo.

¿El BOM es lo mismo que un espacio de ancho cero?

Mismo punto de código, distinto trabajo. U+FEFF en el desplazamiento 0 es una marca de orden de bytes. En cualquier otro lugar del documento es ZERO WIDTH NO-BREAK SPACE, un uso que Unicode desaconsejó en favor de U+2060 WORD JOINER. El texto antiguo todavía lo contiene, y por eso U+FEFF aparece en medio de los archivos.

¿El BOM afecta a los diffs de git y al tamaño del archivo?

Tres bytes en disco, y una línea de ruido en cada diff que lo toca. Git compara bytes, así que agregar o quitar un BOM reescribe la línea 1 aunque el texto renderizado sea idéntico. De ahí sale ese cambio de una sola línea que nadie en la revisión sabe explicar.

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

Artículos relacionados

Ver todos los artículos