JSON es más simple de lo que crees, y ese es el problema
Publicado el 8/8/2025 · 20 min de lectura · Herramientas para desarrolladores
Daniel Okonkwo — Desarrollador front-end y redactor de Tecnología en OneKitly
Rendimiento web · Formatos de archivo
Verificado con 7 fuentes
La gramática de JSON cabe en una página, y justo por eso tiene fugas. ECMA-404 y la RFC 8259 definen seis clases de valor — objeto, array, cadena, número, true o false, null — y nada más. No hay tipo entero: JSON tiene un único tipo numérico, y un analizador de JavaScript lo posa sobre un double IEEE 754, de modo que 1234567890123456789 vuelve como el double 1234567890123456768, un error de 21, mientras que el analizador de Python lo devuelve exacto. Los mismos bytes, dos valores distintos. No hay tipo fecha: una marca de tiempo es una cadena cuyo formato es una convención. NaN e Infinity no se pueden escribir — JSON.stringify los convierte en null y JSON.parse rechaza los literales, aunque el módulo json de Python los emite por omisión y produce documentos que no son JSON. El cero negativo sobrevive de forma asimétrica: analizar -0 da -0, pero serializar -0 da 0. Las claves duplicadas son legales en la gramática, la RFC 8259 solo dice que el resultado es impredecible, y todos los analizadores habituales se quedan en silencio con la última. Los comentarios no están en la gramática. Un JSON Schema generado fija la forma, nunca el significado, y el deducido de una sola muestra sobreajusta mucho. Y el orden de las claves es un hecho a nivel de bytes: intercambia dos y la carga útil se hashea distinto, lo que rompe las firmas.
JSON no tiene tipo entero, ni tipo fecha, ni comentarios, ni esquema. Cada una de esas ausencias produce un fallo concreto: un identificador de 19 dígitos vuelve desviado en 21, una marca de tiempo se convierte en una cadena que nadie acordó, NaN no se puede escribir y las claves duplicadas son legales. Todo ejecutado, en dos lenguajes.
Seis clases de valor, y ninguna es la que querías
JSON está especificado dos veces, por ECMA-404 y por la RFC 8259, y ambos documentos son breves porque hay muy poco que decir. Un valor es un objeto, un array, una cadena, un número, el literal true, el literal false o el literal null. Ese es todo el sistema de tipos. Todo lo demás que crees que JSON tiene lo añadió tu lenguaje a la entrada o a la salida.
Cuenta las ausencias. Sin tipo entero, solo una producción numérica única que el analizador debe proyectar sobre el tipo numérico que tenga. Sin fecha, sin hora, sin duración. Sin binario — obtienes base64 dentro de una cadena, lo que cuesta un tercio más de bytes. Sin comentarios. Sin enumeraciones, sin rangos, sin campos obligatorios, sin esquema alguno. Sin garantía sobre el orden de las claves. Sin forma de expresar una referencia a otra parte del mismo documento: un grafo hay que aplanarlo a mano.
Nada de eso es un defecto de diseño. JSON se extrajo de una sintaxis literal de JavaScript para mover datos entre dos programas que ya estaban de acuerdo en qué significaban, y hace ese trabajo con un tamaño y una velocidad que nada ha superado. El defecto aparece cuando un formato que no transporta significado se usa como si lo transportara. Todo lo que sigue es una instancia de ese error.
El número que vuelve mal
La gramática numérica de JSON admite cualquier decimal que puedas escribir. La RFC 8259 advierte de que las implementaciones varían y recomienda quedarse dentro de la doble precisión IEEE 754, lo que en la práctica significa enteros hasta 2^53 − 1 = 9 007 199 254 740 991. Por encima, los doubles dejan de poder representar enteros consecutivos, y un analizador de JavaScript no tiene otro sitio donde poner el valor.
Ejecútalo. JSON.parse('9007199254740993') devuelve 9007199254740992 — la entrada era impar, la salida es par, y no se lanzó ningún error. Toma un identificador realista de 19 dígitos del tipo que reparten las plataformas sociales y los servicios de mensajería: JSON.parse('{"id": 1234567890123456789}') guarda el double 1234567890123456768, que JavaScript luego imprime como 1234567890123456800 porque es el decimal más corto que vuelve al mismo double. El identificador que recibiste difiere del que guardaste en 21, y del que imprimes en 32. Un id más corto de 18 dígitos, 175928847299117063, vuelve como 175928847299117060.
Ahora entrega los bytes idénticos a Python 3.13: json.loads devuelve exactamente 1234567890123456789, como int de Python, porque el analizador de Python proyecta la producción numérica JSON sobre un entero de precisión arbitraria cuando no hay parte fraccionaria. Un documento, dos lenguajes, dos valores distintos, y ambos analizadores son conformes. Ahí está todo el problema en una línea: JSON no le dice al analizador qué tipo numérico usar, así que el analizador decide, y la decisión no es la misma en todas partes.
El mismo agujero se traga la distinción entero/decimal en el otro sentido. JSON.stringify(1.0) produce los dos caracteres "1", así que un campo que tu base declaró decimal llega como algo que un generador de esquemas etiquetará como entero. No hay forma de escribir 1.0 en JSON y que siga siendo 1.0 tras un viaje de ida y vuelta por JavaScript. El arreglo para los identificadores es brusco y universal: envíalos como cadenas. Las plataformas mordidas pronto entregan ambos campos — un id numérico y un id de texto — precisamente porque no pudieron arreglar a sus clientes.
Fechas, NaN, Infinity y un signo menos que desaparece
JSON.stringify(new Date(...)) produce "2026-08-28T14:30:00.000Z", lo que parece indicar que JSON entiende de fechas. No. ECMAScript define Date.prototype.toJSON, y JSON.stringify lo llama; el resultado es una cadena corriente. Vuelve a analizarla y typeof da "string". El formato resulta ser el que la RFC 3339 perfila a partir de ISO 8601, pero nada en JSON lo exige, y en cuanto un segundo servicio serializa con otra biblioteca obtienes una fecha con otra forma — un entero epoch, una hora local sin desfase, una envoltura "/Date(1234567890)/" de una pila más antigua. Todas son JSON igual de válido e igual de imposible de interpretar sin un acuerdo previo.
NaN y los dos infinitos no están en la gramática, así que no se pueden escribir en absoluto. JSON.stringify({a: NaN, b: Infinity, c: -Infinity}) devuelve {"a":null,"b":null,"c":null} — tres valores en coma flotante distintos colapsados en un solo null, en silencio. JSON.parse('{"a":NaN}') lanza un SyntaxError. Dentro de un array, undefined, las funciones y los símbolos también se vuelven null; como valores de objeto se eliminan por completo, de modo que {a: undefined, b: 1} se serializa como {"b":1} y una clave sencillamente desaparece.
Python hace algo peor y más interesante: json.dumps({'a': nan, 'b': inf}) emite por omisión {"a": NaN, "b": Infinity}, y json.loads lo relee tan tranquilo. Esa salida no es JSON. Atravesará tus propios servicios Python intacta y fallará en cuanto llegue a un analizador conforme en cualquier otro lenguaje, que suele ser el navegador, que suele ser producción. La opción existe — allow_nan=False lanza una excepción — y casi nadie la activa.
El cero negativo es el más pequeño y el más raro del conjunto. IEEE 754 tiene dos ceros, y cuentan allí donde un signo lleva información — una tasa de variación, un sentido de redondeo, un saldo que llegó exactamente a nada desde abajo. JSON.parse('-0') devuelve -0: Object.is(JSON.parse('-0'), -0) es true, y 1 dividido por él da −Infinity. Pero JSON.stringify(-0) devuelve el único carácter 0. Así que el valor sobrevive en un sentido y no en el otro, y un viaje completo de ida y vuelta cambia 1 ÷ x de −Infinity a +Infinity sin aviso alguno.
Las claves duplicadas son legales, y cada analizador elige una en silencio
La gramática JSON define un objeto como una secuencia de pares nombre-valor separados por comas. No dice que los nombres tengan que diferir. La RFC 8259 lo trata en prosa y no en la gramática: los nombres DEBERÍAN ser únicos, y advierte de que las implementaciones ante un duplicado se comportan de forma distinta — unas toman la última, otras la primera, otras informan de un error. Ese DEBERÍAN es la palabra más débil que la RFC podía usar, y significa que cualquier analizador que encuentres acepta el documento.
En la práctica el grueso convergió. JSON.parse('{"role":"admin","role":"user"}') devuelve {"role":"user"} en Node, y json.loads de Python devuelve lo mismo. Gana la última, sin aviso, sin manera de detectar a posteriori que el documento tuvo dos. La información de que existía un duplicado la destruye el propio análisis, y eso es lo que dificulta la depuración: cuando tu código ve el objeto, la prueba ya no está.
La consecuencia que hay que tomarse en serio es que un documento puede significar una cosa para el componente que lo comprueba y otra para el que actúa sobre él, si esos dos componentes usan analizadores que discrepan — o si uno inspecciona el texto crudo y el otro el objeto analizado. La forma general de ese peligro y la regla que se deriva están tratadas en el artículo de este sitio sobre verificación de tokens firmados: valida y actúa sobre la misma representación analizada, nunca sobre dos. La mitigación concreta aquí es más simple. Rechaza los documentos con nombres duplicados en la entrada, antes de que nada más los mire; un analizador en flujo o una pasada de tokenización previa ve el duplicado que JSON.parse tira.
Qué aporta un esquema generado y dónde sobreajusta
JSON Schema tapa el mayor agujero: es un vocabulario para decir qué claves deben existir, qué tipo tiene cada valor, qué valores se permiten y hasta dónde llega el anidamiento. Un validador convierte un documento informe en un sí o un no en el borde de tu sistema, y eso vale mucho. Generar un primer borrador a partir de una muestra que ya tienes es el camino más rápido, y es lo que hace el generador de esquemas de este sitio.
La trampa es que un esquema deducido de un solo documento describe ese documento, no la familia a la que pertenece. Toma una muestra de aspecto inofensivo: un objeto con un id entero de 42, un nombre, un array de una sola etiqueta de texto, un score entero de 10, un manager que resulta ser null y un booleano. Un generador ingenuo produce tipo entero para id y score, tipo null para manager, array de cadenas para tags, todas las claves en required y additionalProperties en false.
Ahora valida contra él cinco documentos posteriores perfectamente legítimos. Un score que llega como 10.5 se rechaza, porque la muestra era entera. Un manager por fin rellenado con un objeto se rechaza, porque la muestra era null. Un documento que omite un campo opcional se rechaza, porque el generador puso todas las claves en required. Un documento con un nuevo campo email se rechaza, porque additionalProperties era false. Un array tags que contiene un número se rechaza. Cinco de cinco, y cada uno es un registro real que tu sistema debería haber aceptado.
La otra mitad de la lección es lo que el mismo esquema acepta encantado: un registro con nombre vacío y un score de −999 pasa todas las comprobaciones, porque JSON Schema valida forma y nunca significado. Nada en el vocabulario sabe que un nombre debería ser no vacío o que un score tiene un suelo. Usa pues la generación como primer borrador y luego edítalo a mano: ensancha integer a number donde sea posible un decimal, sustituye un tipo null por una unión anulable, recorta required a los campos realmente obligatorios, deja additionalProperties abierto salvo que estés cerrando el contrato a propósito, y añade las restricciones minLength, minimum y enum que llevan tus reglas de negocio reales.
El orden de las claves y la firma que deja de coincidir
Los objetos JSON son no ordenados como modelo de datos, pero un documento JSON es una secuencia de bytes y los bytes tienen orden. JSON.stringify emite las claves de texto en orden de inserción — con una excepción que pilla a mucha gente. ECMAScript coloca primero las claves de índice entero, ascendentes, por delante de toda clave de texto. Construye un objeto asignando z, luego user_2, luego "2", luego user_1, luego "1": stringify devuelve {"1":5,"2":3,"z":1,"user_2":2,"user_1":4}. Las dos claves de aspecto numérico saltaron al frente y se ordenaron numéricamente; el resto se quedó en el orden que escribiste. El análisis hace lo mismo: un documento recibido en un orden sale de JSON.parse en otro.
Eso se convierte en un incidente de producción en cuanto hasheas una carga útil. Dos servicios describen la misma transferencia de 100 €: uno escribe {"amount":100,"currency":"EUR","to":"acct_9"} y el otro los mismos tres campos empezando por "to". Los objetos son profundamente iguales. Los resúmenes SHA-256 son 1648f3b9016a5b95… y bc654befe505d093…, y un HMAC calculado sobre cada uno difiere desde el primer byte. El receptor rechaza una petición que es, semánticamente, exactamente la que esperaba.
Ordenar las claves antes de serializar arregla este caso concreto — ambos objetos se canonizan a la forma que empieza por amount y los resúmenes coinciden. Pero ordenar por sí solo no es una forma canónica, porque el mismo valor todavía puede escribirse de más de una manera: "é" y "\u00e9" son la misma cadena y bytes distintos, 1e21 y 1000000000000000000000 son el mismo número, y un serializador puede escapar o no la barra. La RFC 8785, JSON Canonicalization Scheme, es la respuesta normalizada: fija el orden de claves por unidad de código UTF-16, ata el formato de los números a las reglas de ECMAScript y define exactamente qué caracteres se escapan. Si puedes evitar el problema, evítalo: firma y verifica los bytes exactos que recibiste, y nunca reserialices un documento que vas a comprobar.
La lista práctica
Envía cada identificador como cadena, sea cual sea su tipo en tu base de datos. Acordad por escrito un único formato de marca de tiempo — la RFC 3339 con desfase explícito es el menos discutido — y rechaza cualquier otro en la frontera en vez de adivinar. Decide de antemano qué significa un valor ausente, y elige o null o la ausencia, no las dos. Nunca dejes que un NaN o un infinito llegue a un serializador: conviértelo en null, en cadena o en error, deliberadamente, allí donde ocurre el cálculo.
Rechaza los nombres duplicados en la entrada. Genera un esquema para ahorrar tecleo, y luego edítalo antes de confiar en él. Canoniza, o firma los bytes crudos, nunca un objeto reserializado. Y guarda los ficheros de configuración, donde los humanos necesitan comentarios y comas finales, en un formato que los tenga — que es el tema del artículo siguiente.
| En el documento | Node devuelve | Python devuelve | Consecuencia |
|---|---|---|---|
| 9007199254740993 | 9007199254740992 | 9007199254740993 (exacto) | Un número impar se vuelve par, sin error |
| 1234567890123456789 | 1234567890123456768, impreso como 1234567890123456800 | 1234567890123456789 (exacto) | Identificador desviado en 21; los dos servicios discrepan |
| 1.0 | 1, y JSON.stringify lo reescribe como "1" | 1.0 como flotante, reescrito como 1.0 | Distinción decimal/entero perdida solo en un lenguaje |
| Una Date serializada, "2026-08-28T14:30:00.000Z" | Una cadena (typeof es "string") | Una cadena | No hay tipo fecha; el formato es una convención |
| NaN escrito como literal | SyntaxError — rechazado | nan — aceptado, y emitido por omisión | Python escribe documentos que no son JSON |
| -0 serializado desde un programa | Se escribe 0; el signo ha desaparecido | Se escribe -0.0; el signo sobrevive | 1 ÷ x pasa de −Infinity a +Infinity |
| {"role":"admin","role":"user"} | role = user (gana el último) | role = user (gana el último) | Gramática legal, impredecible según la RFC 8259 |
Preguntas frecuentes
- ¿Cómo paso un identificador de 64 bits por JSON sin perder dígitos?
- Envíalo como cadena. Es el único arreglo que funciona en todas partes, y por eso las plataformas mordidas primero publican dos campos — un id numérico y una versión de texto del mismo id — en vez de romper a sus clientes. Un reviver de JSON.parse no te ayudará: el reviver se ejecuta después de que el tokenizador ya haya producido el double, así que cuando tu callback ve el valor los dígitos ya no están. Existen analizadores compatibles con bigint y sí funcionan, porque leen el texto del token y deciden el tipo ellos mismos, pero cambian lo que recibe tu código y hay que auditar cada comparación, JSON.stringify y operación aritmética aguas abajo. Si no puedes cambiar el productor, detecta al menos el daño: un entero cuyo valor absoluto supere Number.MAX_SAFE_INTEGER, 9007199254740991, ya no es fiable, y un viaje por String(BigInt(x)) comparado con el token crudo te dirá si sobrevivió. Y cuando pases a cadenas, recuerda que un identificador de texto se ordena lexicográficamente: "10" va antes que "9", así que cualquier orden del que dependieras debe mudarse a un campo numérico aparte o a la base de datos.
- ¿Las claves duplicadas son realmente JSON válido?
- Sí, gramaticalmente. Ni ECMA-404 ni la gramática de la RFC 8259 prohíben un nombre repetido, así que un documento que lo contenga se analiza. La RFC 8259 añade un requisito en prosa al nivel DEBERÍA — los nombres deberían ser únicos — y advierte de que las implementaciones difieren cuando no lo son, listando tres comportamientos plausibles: quedarse con el último, con el primero, o informar de un error. Medido aquí, JSON.parse de Node y json.loads de Python se quedan ambos con el último, así que {"role":"admin","role":"user"} da el rol user en los dos. Como el propio análisis destruye la prueba, no puedes detectar el duplicado desde el objeto resultante, y ninguna validación posterior lo encontrará. La regla práctica es rechazar en la entrada: o un analizador en flujo o basado en eventos que informe de cada nombre según aparece, o una pasada de tokenización barata que cuente nombres por objeto, y rechazas la petición. Un esquema no lo hará por ti — JSON Schema opera sobre la instancia analizada, momento en el que el duplicado ya está resuelto.
- JSON no tiene comentarios. ¿Qué uso para los ficheros de configuración?
- Los comentarios se dejaron fuera a propósito, con el razonamiento de que la gente empezaría a meter directivas de análisis en ellos. Las comas finales, las cadenas con comillas simples y las claves sin comillas faltan por la misma razón: la gramática es pequeña para que todas las implementaciones coincidan. Es una buena propiedad para datos en tránsito y una pésima para un fichero que mantiene una persona. El reparto honesto es usar JSON estricto para todo lo que una máquina produce o transmite, y algo más amable para todo lo que edita una persona. JSONC — JSON con comentarios — es lo que aceptan varios editores y cadenas de herramientas, y es el paso más pequeño. JSON5 añade comas finales, claves sin comillas, comillas simples, números hexadecimales y los literales NaN e Infinity que a JSON le faltan. TOML está diseñado específicamente para configuración y tiene fechas de verdad. YAML es el más desplegado y es el tema del artículo siguiente de esta serie, incluidas las formas en que su inferencia de tipos te sorprenderá. Lo único que no hay que hacer es una clave "_comment": es legal, sobrevive a los viajes de ida y vuelta, y sobrevive también hasta lo que serialices después, donde nadie la espera.
- ¿Un esquema generado sustituye a la validación escrita a mano?
- No, por dos razones distintas. Primero, la generación sobreajusta: medido arriba, un esquema deducido de un solo registro rechazó cinco de cinco registros posteriores legítimos — un decimal donde la muestra tenía un entero, un campo relleno donde la muestra tenía null, un opcional ausente, un campo añadido y un array de tipos mixtos. Cada uno es una evolución normal de una carga útil real. Segundo, JSON Schema valida forma y no significado por diseño, así que el mismo esquema aceptó sin rechistar un registro con nombre vacío y un score de −999. Para lo que la generación sirve de verdad es para la parte tediosa: enumerar cincuenta claves y sus tipos sin una errata, y darte un fichero de partida que ya se analiza. Trata la salida como un borrador y haz cuatro ediciones antes de confiar en ella — ensancha integer a number donde pueda aparecer un decimal, haz de los campos anulables una unión en vez del tipo null, poda required a lo realmente obligatorio, y decide conscientemente si additionalProperties debe ser false. Añade después las restricciones que llevan las reglas de negocio, porque son exactamente las que ningún generador puede deducir de los datos.
- ¿Por qué dos servicios calculan hashes distintos de la misma carga útil?
- Porque un hash es sobre bytes y los dos servicios produjeron bytes distintos para el mismo valor. Varían tres cosas de forma independiente. El orden de las claves es el culpable habitual: dos objetos profundamente iguales se serializan distinto si sus claves se insertaron en otro orden, y ECMAScript además iza al frente las claves con aspecto de índice entero en orden numérico ascendente, de modo que "2" y "10" se adelantan a todas las demás independientemente de dónde las escribieras. El escapado de cadenas es el segundo: "é" escrito directamente y escrito \u00e9 son la misma cadena y bytes distintos, y los serializadores discrepan sobre escapar la barra y los dos separadores de línea U+2028 y U+2029. El formato de los números es el tercero: 1e21 y su forma decimal larga denotan el mismo double, y 1.0 se serializa como 1. El arreglo correcto depende de dónde estés. Si estás verificando algo recibido, hashea los bytes exactos que llegaron y no los reserialices nunca, lo que esquiva los tres problemas de golpe. Si tienes que hashear un valor que construiste tú, usa una forma canónica definida: la RFC 8785 especifica una que fija a la vez el orden de claves, el formato de los números y el escapado, y hay bibliotecas que la implementan en la mayoría de lenguajes.
- ¿Es seguro JSON.parse sobre entrada no confiable?
- Estructuralmente sí, y mucho más seguro que el eval al que sustituyó: la gramática no contiene ninguna construcción ejecutable, así que un documento analizado no puede ejecutar código. Surgen dos preocupaciones concretas y ambas son menos alarmantes que su reputación. La contaminación de prototipo no la causa JSON.parse — la especificación exige que cree propiedades de datos, de modo que JSON.parse('{"__proto__": {"admin": true}}') da un objeto con una propiedad propia corriente llamada __proto__ y deja Object.prototype intacto; verificado aquí, ({}).admin sigue siendo undefined. La contaminación ocurre después, en una fusión recursiva ingenua o un bucle de asignación sin protección que recorre esas claves: ahí es donde va la guarda. El desbordamiento de pila por anidamiento profundo también es en gran medida histórico: el analizador de V8 es iterativo, y un millón de niveles de arrays anidados se analizaron sin error en Node 26. Lo que sí merece limitarse es el tamaño y el tiempo. Un analizador debe leer el documento entero antes de producir nada, así que un cuerpo sin límite significa memoria sin límite, y las claves duplicadas, los números desmesurados y los campos inesperados siguen necesitando rechazo en la frontera. Limita el cuerpo de la petición y luego valida.
Artículos que podrían interesarte
Todas las guías →Herramientas relacionadas
Fuentes
- Ecma International — ECMA-404: The JSON Data Interchange Syntax
- IETF — RFC 8259 — The JavaScript Object Notation (JSON) Data Interchange Format
- JSON Schema — JSON Schema specification — core and validation vocabularies
- Ecma International / TC39 — ECMA-262 — JSON.parse, JSON.stringify, Date.prototype.toJSON and OrdinaryOwnPropertyKeys
- IETF — RFC 8785 — JSON Canonicalization Scheme (JCS)
- IETF — RFC 3339 — Date and Time on the Internet: Timestamps
- Python Software Foundation — json — JSON encoder and decoder (allow_nan, parse_int, and the non-standard NaN/Infinity extension)
¿Has detectado un error en este artículo?