JSON Toolkit

Depurar JSON minificado: de la línea 1 columna 23611 al byte exacto que falla

En resumen: Un archivo JSON de 4 MB en una sola línea informa de línea 1 columna 23611, lo que no te dice nada. Con muestras medidas localmente, esta guía explica por qué Python, Node y jq informan de tres números distintos, cómo un desplazamiento de caracteres te lleva directo al byte roto y cuáles son las tres trampas que corrompen datos en silencio después de arreglar la sintaxis.

Avanzado~7 min de lecturaPublicado el 2026-11-01Actualizado el 2026-11-01
Portada de Localizar errores en JSON minificado

En resumen: los números de línea y columna solo significan algo en documentos multilínea. Una vez que el JSON está minificado en una sola línea, todos los errores viven en la línea 1: el número que necesitas no es la línea, es el índice de carácter.

Un documento JSON minificado tiene exactamente una línea, así que el par línea/columna de un mensaje de error colapsa una posición de cuatro millones de bytes en una frase inútil. El truco es ignorar por completo línea/columna y leer en su lugar el desplazamiento de caracteres: el humilde (char 23610) al final del mensaje es el número que realmente te salva.

Todos los números de abajo se produjeron localmente. La muestra es un documento de una sola línea con 13.000 eventos de facturación, 2.011.084 caracteres en total, con una coma borrada deliberadamente después del event_id del evento n.º 154. El entorno es Python 3.11.1, Node 22.13.1 y jq 1.7.

1. Primero, los hechos: tres analizadores, tres números

El mismo archivo roto, pasado a tres herramientas:

Python 3.11.1  Expecting ',' delimiter: line 1 column 23611 (char 23610)
Node  22.13.1  Expected ',' or '}' after property value in JSON at position 23610 (line 1 column 23611)
jq    1.7      Expected separator between values at line 1, column 23619     exit code 5

Lado a lado:

Herramienta Valor informado Unidad Contado desde Por qué falla línea/columna
Python JSONDecodeError.pos 23610 Desplazamiento de caracteres Inicio del búfer El único en el que puedes confiar
Python lineno / colno 1 / 23611 Línea y columna Inicio de línea Solo hay una línea, así que no dice nada
Node position 23610 Desplazamiento de caracteres Inicio del búfer Fiable, aunque el mensaje oculta el campo
jq column 23619 Desplazamiento de caracteres Inicio del token problemático Difiere de Python en 9

La conclusión es rotunda: el char N de Python y el position N de Node son el mismo número y coinciden. jq usa un esquema propio: no pierdas el tiempo intentando conciliarlo.

Los tres números difieren no porque alguien haya calculado mal, sino porque cuentan cosas distintas. Python y Node informan de dónde se detuvo el analizador; jq informa de dónde empieza el token que no pudo analizar. Entre ambos hay un puñado de caracteres ya consumidos: 9 en esta muestra.

2. Por qué la línea 1 columna N es ruido en este tipo de archivo

Los números de línea son una cantidad derivada que asume que "ha pasado un tiempo desde el último salto de línea". Un documento minificado tiene un solo \n, normalmente al final, así que:

  • lineno siempre es 1;
  • colno degenera en "cuántos caracteres después del inicio de esa única línea";
  • y esa línea tiene cuatro millones de caracteres de ancho.

Salta a la columna 23611 en un editor y no verás nada útil. Peor aún, herramientas como less y vim se arrastran con líneas extremadamente largas; mover el cursor con las teclas de flecha quema veinte minutos.

Dale la vuelta: el desplazamiento es la cantidad nativa, y línea/columna se calculan a partir de él. Usa la cantidad nativa.

3. Enfoque uno: en Python, usa e.pos e ignora lineno

JSONDecodeError lleva tres números, y solo merece la pena leer pos. lineno y colno son valores derivados que colapsan a nada en un documento de una sola línea.

# locate.py - Python 3.11.1
import json
import sys

path = sys.argv[1]
raw = open(path, encoding='utf-8').read()

try:
    json.loads(raw)
except json.JSONDecodeError as error:
    print(f'{type(error).__module__}.{type(error).__name__}: {error}')
    start = max(0, error.pos - 60)
    end = min(len(raw), error.pos + 60)
    print(f'\nchar {start}..{end} of {len(raw)}:')
    print(raw[start:end])
    print(' ' * (error.pos - start) + '^')
    sys.exit(1)

print('valid JSON')

Ejecútalo contra el archivo roto:

$ python3 locate.py bench/minified-json-error-position/broken.json
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 23611 (char 23610)

char 23550..23670 of 2011083:
ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
                                                            ^

El signo de intercalación marca el punto donde falta una coma. Veinte minutos se convierten en un comando.

Qué ancho debe tener la ventana: ±60 caracteres casi siempre abarca un par clave/valor completo. Demasiado estrecha y pierdes contexto; demasiado ancha y la salida llena la pantalla. Los documentos profundamente anidados pueden ir a ±120.

4. Enfoque dos: no hay Python en el contenedor; usa jq más cut

Los contenedores de producción a menudo no incluyen Python, pero casi siempre tienen jq, o pueden conseguirlo con un apt-get install jq. Lo que jq te da es un fallo ruidoso:

$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5

El código de salida 5 significa entrada inválida, así que bajo set -e aborta el script igual que lo hace grep al devolver 1. Eso es una ventaja.

jq no tiene ninguna opción para imprimir los bytes problemáticos, así que pasa la posición a cut:

# Fíjate en -c (caracteres), no en -b (bytes)
$ cut -c23550-23670 bench/minified-json-error-position/broken.json
"ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",

Una vez que la coma vuelve, jq puede hacer aquello en lo que realmente es bueno:

$ jq -r '.events | length' bench/minified-json-error-position/payload.json
13000
$ jq -r '.events[154] | "\(.event_id) \(.plan) \(.amount_cents)"' bench/minified-json-error-position/payload.json
evt_000154 pro 74479

5. Enfoque tres: sin runtime, o datos que no deben tocar el disco; pégalo en un navegador

Los dos primeros enfoques requieren algo instalado y permiso para escribir archivos. Si estás en un portátil pelado, o la carga está llena de correos e IP de clientes y "lo pegué en una web" es una frase que no quieres en el informe del incidente, la tercera opción encaja.

Pega la cadena completa en un analizador JSON en línea y este coloca el cursor en el carácter problemático e indica la línea y columna; la ventaja de una herramienta local es que esos 4 MB nunca salen de la máquina y nunca acaban en los registros de otro.

Dos advertencias:

  1. Elige una herramienta que diga explícitamente que procesa todo en el navegador, nunca una que haga un POST de tu contenido a un backend;
  2. Pegar tolera archivos muy grandes, pero pasados unos cuantos megabytes la propia pestaña consume memoria; en ese punto vuelve al enfoque uno.

6. El desplazamiento de caracteres no es el desplazamiento de bytes: la segunda trampa con datos CJK

Esta muerde a ambos enfoques con cargas mixtas de chino/inglés.

JSONDecodeError.pos es un desplazamiento de caracteres (un recuento de puntos de código Unicode), porque resulta de analizar un str. cut -c corta por carácter y coincide con él. Pero head -c, tail -c y dd cortan por byte, y en UTF-8 un solo resbalón lo descoloca todo.

Una muestra china lo hace concreto (5 pedidos, 148 caracteres, 222 bytes):

>>> raw = open('bench/minified-json-error-position/cn.json', encoding='utf-8').read()
>>> raw[25:35]                                    # caracteres 25-35
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35]      # bytes 25-35
b'DD00000","'

Mismo desplazamiento: la vista de caracteres aterriza en un nombre de campo, la vista de bytes aterriza en el final de un ID de pedido. Un solo carácter CJK ocupa 3 bytes en UTF-8, y la desviación se acumula con la posición.

Regla: después de leer e.pos, corta con cut -c o con un slice de Python, nunca con head -c / dd bs=1 skip=N. Si las herramientas de bytes son todo lo que tienes, convierte primero: len(raw[:error.pos].encode('utf-8')).

7. Sintaxis arreglada, datos aún incorrectos: tres trampas silenciosas

Esto es exactamente por lo que la depuración no puede detenerse en "se analizó". Cada una de las siguientes o nunca lanza error, o lo lanza mucho después de que el valor se haya usado.

Trampa 1: claves duplicadas — gana la última, nadie se queja

La sección 4 de RFC 8259 es clara: los nombres dentro de un objeto DEBERÍAN ser únicos, y cuando no lo son el comportamiento es impredecible: la mayoría de las implementaciones conserva la última aparición, otras dan error. Las tres herramientas medidas aquí conservan la última:

Input   {"order_id":"A-1001","total":4200,"order_id":"A-1002"}
Node    {"order_id":"A-1002","total":4200}
Python  {'order_id': 'A-1002', 'total': 4200}
jq      {"order_id":"A-1002","total":4200}

Ni una sola advertencia. Si el campo sobrescrito era amount_cents, te enteras cuando la conciliación se descuadra en tres céntimos.

Trampa 2: NaN e Infinity — Python los acepta, Node no

La sección 6 de RFC 8259 dice claramente que los literales numéricos no contienen NaN ni Infinity. El json.loads de Python acepta ambos por defecto, porque su propio float tiene esos valores:

>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}'    # se escribe tal cual de vuelta

Pasa esa salida a Node y obtienes:

SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON

Es decir: los datos malos pasan por Python y estallan en el consumidor de aguas abajo.

Trampa 3: los enteros más allá de 2^53 pierden precisión en silencio

Un documento, dos valores distintos:

JSON source   {"order_no":12345678901234567890}
Python        12345678901234567890          <- kept as-is
Node          12345678901234567000          <- last three digits flattened

La razón en una línea: Number.MAX_SAFE_INTEGER de JavaScript es 9007199254740991 (2^53-1). MDN señala específicamente que el reviver se ejecuta después de que el valor ya se haya analizado a un número de JavaScript, así que la precisión se pierde antes del reviver y no puede recuperarse en el lado de la deserialización. La única solución es que el productor serialice los ID grandes como cadenas.

Las tres trampas comparadas:

Trampa ¿Lanza algo? ¿Quién se entera primero Cómo interceptarla
Claves duplicadas No La conciliación aguas abajo Comprobación explícita con object_pairs_hook
NaN / Infinity Node lanza, Python calla Los consumidores aguas abajo Rechazar en el borde con parse_constant
Enteros más allá de 2^53 No El código que hace comparaciones de igualdad Envíalos como cadenas

Captura las dos primeras en una sola pasada

# check.py - Python 3.11.1
import json
import sys
from collections import Counter


def no_dupes(pairs):
    counts = Counter(key for key, _ in pairs)
    dupes = sorted(key for key, n in counts.items() if n > 1)
    if dupes:
        raise ValueError(f'duplicate key(s): {dupes}')
    return dict(pairs)


def reject_constant(name):
    raise ValueError(f'non-standard JSON constant: {name}')


raw = open(sys.argv[1], encoding='utf-8').read()
try:
    data = json.loads(raw, object_pairs_hook=no_dupes, parse_constant=reject_constant)
except json.JSONDecodeError as error:
    start, end = max(0, error.pos - 60), min(len(raw), error.pos + 60)
    print(f'{error}\n\nchar {start}..{end} of {len(raw)}:')
    print(raw[start:end])
    print(' ' * (error.pos - start) + '^')
    sys.exit(1)
except ValueError as error:
    print(f'rejected: {error}')
    sys.exit(1)

print(f'valid JSON, {len(raw)} chars')

El coste, dicho con honestidad: en la misma muestra de 2.011.084 caracteres, esta versión estricta llega a 35,2-36,2 ms frente a 9,4-9,9 ms de la permisiva, unas 3,7 veces más lenta (3,6 a 3,8 veces en cinco rondas de cinco ejecuciones cada una, mediana por ronda): object_pairs_hook ejecuta un Counter por cada objeto. Mantenla fuera del camino feliz de CI y cámbiala solo cuando una entrega parezca sospechosa.

8. Lista de comprobación

Después de arreglar el archivo es tentador formatearlo bonito para que sea legible, pero la misma muestra mide 2.011.084 caracteres minificada (separators=(',',':')) y 2.869.100 caracteres con indent=2: un 43% más. Ese peso viaja al historial de Git, a las cachés de CI y a la transferencia de red, así que mantén la versión bonita en local y vuelve a comprimirla a una línea antes de publicar.

  • [ ] Busca primero (char N) / position N; ignora line 1 column N;
  • [ ] Python usa e.pos, jq se combina con cut -c: los mismos desplazamientos de caracteres, así que nunca concilies el column de jq con ellos;
  • [ ] Para documentos CJK corta por carácter (cut -c, slices de Python), nunca head -c / dd;
  • [ ] Los scripts de contenedor deben tratar el código de salida 5 de jq como "entrada inválida";
  • [ ] Vuelve a revisar los datos sospechosos con object_pairs_hook + parse_constant y acepta el coste de unas 3,7 veces;
  • [ ] Envía como cadena cualquier ID por encima de 2^53; no esperes que un reviver lo rescate;
  • [ ] La salida formateada bonita (medida +43%) se queda en local: usa /minify para comprimir de nuevo a una línea antes de publicar o confirmar; por encima de unos cuantos MB pásate al streaming, tratado en Analizar, renderizar y transmitir JSON grande.

Preguntas frecuentes

¿Por qué las tres herramientas informan de una posición distinta?

Porque cuentan cosas distintas. El char N de Python y el position N de Node significan ambos "dónde se detuvo el analizador"; en esta muestra ambos son 23610, así que coinciden. jq informa de dónde empieza el token problemático, 23619 aquí, nueve más allá. Nadie calculó mal y no hace falta ninguna corrección; compara solo números dentro de la misma herramienta.

¿Debo usar cut -c o head -c?

Usa cut -c. JSONDecodeError.pos es un desplazamiento contado en caracteres Unicode, y cut -c corta por carácter, así que ambos concuerdan. head -c, tail -c y dd cortan por byte, lo que se desvía en documentos UTF-8 con texto CJK e incluso puede cortar un carácter por la mitad. En la muestra china, el mismo desplazamiento produce 城市":"杭州"}, desde la vista de caracteres y DD00000"," desde la vista de bytes.

¿De verdad el tratamiento de claves duplicadas está desprotegido en todas partes?

Sí. La sección 4 de RFC 8259 solo dice que los nombres DEBERÍAN ser únicos y afirma que el comportamiento es impredecible cuando no lo son. Python, Node y jq, todos los medidos aquí, aplican gana-la-última sin emitir ninguna advertencia. Atraparlo requiere tu propio object_pairs_hook, a costa de unas 3,7 veces el tiempo de análisis.

¿Por qué Python lee NaN sin problemas mientras Node lanza error?

La sección 6 de RFC 8259 prohíbe NaN e Infinity como literales numéricos. El json.loads de Python los acepta por defecto porque el float propio de Python tiene esos valores, así que los datos malos pasan por Python y se escriben tal cual de vuelta. El JSON.parse de Node sigue estrictamente la especificación y lanza un SyntaxError ante esa salida. Añadir parse_constant en el punto de entrada lo rechaza de inmediato.

¿Los números de pedido de 20 dígitos siempre pierden precisión a través de JSON?

Solo cuando el consumidor los analiza a una doble precisión de IEEE 754. El int de Python tiene precisión arbitraria y conserva 12345678901234567890; Number.MAX_SAFE_INTEGER de JavaScript es 9007199254740991, así que el mismo campo se convierte en 12345678901234567000. MDN señala explícitamente que el reviver se ejecuta después del análisis, así que no puede recuperarse nada aguas abajo: la única solución fiable es que el productor emita cadenas.