Saltar al contenido
jsonbeautifiers
Español

Aplanar JSON anidado a CSV sin perder datos

Cualquier conversor de JSON a CSV toma una docena de decisiones sin documentar por ti. Estas son las decisiones.

Cada afirmación de esta página está medida o tiene fuente. Cuando no es ninguna de las dos, lo dice.

Pasa esto por casi cualquier conversor:

[
  { "id": 1, "name": "Ada", "tags": ["admin"] },
  { "id": 2, "name": "Grace", "tags": ["admin", "ops"], "team": { "name": "core" } }
]

Muchos te devuelven tres columnas: id, name, tags. El objeto team ha desaparecido. Ni truncado, ni señalado, simplemente ausente, porque el conversor leyó las claves del primer objeto y las tomó por el esquema.

El CSV es un rectángulo: un conjunto fijo de columnas, un escalar por celda. El JSON es un árbol con claves opcionales, profundidad arbitraria y arrays en cualquier sitio. No existe una correspondencia correcta entre ambos, solo un conjunto de políticas, y los conversores que parecen sencillos son los que eligieron las políticas por ti sin decírtelo.

Descubrimiento de columnas: unión, no la primera fila

Hay dos formas de decidir cuáles son las columnas. Recorrer todas las filas y reunir la unión de las rutas hoja, o leer un objeto y quedarse con sus claves.

La segunda no es una optimización de rendimiento, es pérdida de datos con una excusa plausible. Papa Parse toma sus campos de las claves del primer objeto salvo que le pases una opción columns explícita:

Papa.unparse([{ a: 1 }, { a: 2, b: 3 }]);
// "a\r\n1\r\n2"   la columna b nunca existió

Papa.unparse(rows, { columns: ['a', 'b'] });
// la unión la aportas tú

json_normalize de pandas toma la unión, que es una de las razones por las que la gente recurre a él. El coste es que un documento disperso produce una tabla ancha y casi vacía, que es la representación honesta de un documento disperso. Si quieres menos columnas, quítalas a propósito.

El streaming lo complica de verdad: con NDJSON no conoces el conjunto de columnas hasta la última línea, así que o guardas el archivo en memoria o haces dos pasadas.

Objetos anidados y el separador que tienes que exponer

Los objetos anidados se aplanan a rutas con puntos, así que {"team": {"name": "core"}} se convierte en team.name. pandas usa . por defecto y te deja cambiarlo:

pd.json_normalize({"user": {"name": {"first": "Ada"}}})
# columna: user.name.first

pd.json_normalize(data, sep="__")
# columna: user__name__first

El separador tiene que ser configurable, porque el punto es un carácter legal en una clave JSON. Estos dos documentos se aplanan a la misma columna:

{ "a": { "b": 1 } }
{ "a.b": 1 }

En cuanto chocan, el viaje de vuelta es adivinanza, y un conversor que deja que uno sobrescriba al otro en silencio ha producido un archivo que se reconstruye con la forma equivocada. O eliges un separador ausente de tus claves, o lo escapas donde aparezca dentro de una. No des por hecho que los puntos nunca aparecen en las claves: en payloads de eventos y analítica aparecen constantemente.

Arrays: cuatro políticas, un valor por defecto sensato

Aquí es donde más divergen los conversores.

Política Salida para tags: ["admin","ops"] Qué cuesta
Columnas indexadas tags.0 = admin, tags.1 = ops El número de columnas lo fija el array más largo del archivo. Una fila con 400 etiquetas da 400 columnas a todas las filas
Unir en una celda tags = admin,ops Se rompe en cuanto un valor contiene el carácter de unión, y [] y [""] se ven idénticos
JSON en una celda tags = ["admin","ops"] Feo, exige un entrecomillado correcto, sobrevive al viaje de ida y vuelta exactamente
Expandir a filas Dos filas, los demás campos repetidos El número de filas ya no coincide con el de registros, así que los agregados sobre las otras columnas cuentan doble

Las columnas indexadas están bien con aridad pequeña y fija: un par latitud/longitud, un trío RGB. Para cualquier cosa sin límite, el número de columnas lo decide tu peor fila y no la típica.

Unir es el valor por defecto más frecuente y el peor, con pérdida en tres direcciones a la vez: el delimitador puede aparecer en los datos, el array vacío y el array con una cadena vacía se funden, y los objetos anidados acaban convertidos a texto de todos modos.

JSON en una celda es el valor por defecto correcto para los arrays que no vas a expandir, porque es la única política exactamente reversible. La celda se entrecomilla según la RFC 4180 con las comillas internas duplicadas, y cualquier lector que sepa que la columna contiene JSON lo parsea de vuelta. Se ve peor en Excel y es correcto.

Expandir es lo adecuado cuando el array es el asunto: las líneas de un pedido, los eventos de una sesión. Eso es lo que hace record_path:

import pandas as pd

data = [
    {"id": 1, "name": "Ada",   "orders": [{"sku": "A1", "qty": 2}]},
    {"id": 2, "name": "Grace", "orders": [{"sku": "B7", "qty": 1},
                                          {"sku": "C3", "qty": 5}]},
]

pd.json_normalize(data, record_path="orders", meta=["id", "name"])
#   sku  qty  id   name
# 0  A1    2   1    Ada
# 1  B7    1   2  Grace
# 2  C3    5   2  Grace

record_path nombra el array que se convierte en filas y meta nombra los campos del padre que se copian a cada una. Fíjate en lo que le pasa a un registro cuyo array orders está vacío: no produce filas y desaparece por completo. Además obtienes un array por pasada, ya que dos arrays hermanos exigirían un producto cartesiano, así que haz una pasada por array y únelas por el id.

Arrays heterogéneos

Un array cuyos objetos tienen claves distintas es el mismo problema de la unión un nivel más abajo. Con columnas indexadas, [{"a":1},{"b":2}] da items.0.a e items.1.b, dos columnas que nunca están ambas rellenas, y con el conjunto de columnas dependiendo ahora de la posición del elemento. Con expansión da dos filas con columnas a y b, lo cual es mejor porque la posición deja de formar parte de la identidad. Los arrays que mezclan escalares y objetos no tienen forma rectangular alguna; serialízalos como JSON en una celda.

Valores sin equivalente en CSV

El CSV tiene un solo tipo: texto. Todo lo demás es convención.

null frente a cadena vacía. JSON los distingue, el CSV no: ,, y ,"", son el mismo valor para la mayoría de lectores, así que el viaje de ida y vuelta funde uno en el otro. Si eso importa, escribe un centinela como \N (la convención de COPY de Postgres), o acepta que los nulos vuelven como cadenas vacías y dilo.

Booleanos. true y false en minúsculas es la grafía de JSON y sobrevive. Excel muestra TRUE/FALSE y algunas herramientas emiten 1/0, y cualquiera de las dos necesita un mapeo explícito a la vuelta.

Números. Una cadena JSON que contiene 007 la lee Excel como 7, y 1E5 se convierte en 100000. El entrecomillado del CSV no lo impide, porque Excel adivina el tipo después de quitar las comillas. Los enteros grandes chocan con el límite de precisión si algo en la cadena los hace pasar por un float, así que emite el texto fuente del número tal cual.

Fechas. JSON no tiene tipo fecha; las cadenas ISO 8601 o RFC 3339 son la convención. Excel convierte una cadena con pinta de fecha como 2026-03-04 en un valor de fecha y la vuelve a mostrar en el formato local de la máquina, y formatos ambiguos como 03/04/2026 pueden volver como un día completamente distinto, así que nunca dejes que una hoja de cálculo sea una escala intermedia.

Mecánica del CSV que muerde

La RFC 4180 es corta y merece la pena seguirla. Los campos que contengan una coma, una comilla doble o un salto de línea deben ir entrecomillados; una comilla doble literal dentro de un campo entrecomillado se escribe dos veces; los finales de línea son CRLF. Los saltos de línea incrustados en un campo entrecomillado son legales y muchísimos lectores de CSV siguen equivocándose con ellos, así que si tus cadenas contienen saltos de línea, prueba primero el consumidor.

El delimitador no siempre es una coma. Excel, en una configuración regional donde el separador decimal es la coma, espera archivos separados por punto y coma, y por eso un CSV válido se abre como una sola columna en la máquina de un compañero. Ofrece un ajuste de delimitador, o incluye la primera línea sep=; que Excel entiende.

Luego está la marca de orden de bytes: Excel lee un CSV en UTF-8 como UTF-8 solo si el archivo empieza con una, y sin ella los caracteres acentuados se decodifican con la página de códigos del sistema y salen destrozados. Esos tres bytes son ruido para cualquier otra herramienta, así que haz del BOM un interruptor y actívalo para la ruta de Excel.

Inyección CSV

Si el primer carácter de una celda es =, +, - o @, Excel, Google Sheets y LibreOffice tratan la celda como una fórmula y la evalúan al abrir. OWASP llama a esto inyección CSV. Algunas guías añaden el tabulador y el retorno de carro a la lista de disparadores.

No puedes salir de esto a base de comillas: el lector quita las comillas de la RFC 4180 antes de evaluar la fórmula. Así que si alguna cadena de tu JSON vino de un usuario y llega a un CSV que alguien abre, le has entregado a un atacante una fórmula ejecutándose en un contexto de confianza. Las fórmulas pueden ir a buscar URLs remotas, lo que significa que las celdas vecinas pueden salir del edificio.

La mitigación es neutralizar el carácter inicial al escribir la celda:

const RISKY = /^[=+\-@\t\r]/;

function safeCell(value) {
  const s = String(value);
  return RISKY.test(s) ? "'" + s : s;
}

El apóstrofo obliga a Excel a tratar el contenido como texto. No sale gratis: para un lector que no sea una hoja de cálculo, ahora forma parte de los datos, así que el viaje de ida y vuelta se rompe para esos valores. Prefijar es lo correcto para archivos que una persona abre en una hoja de cálculo y lo incorrecto para archivos que una máquina vuelve a leer, lo que lo convierte en un interruptor por exportación y no en un valor por defecto oculto.

El camino de vuelta

CSV a JSON tiene una trampa grande: la inferencia de tipos. Todo valor del archivo es texto, así que el conversor adivina cuáles son números y falla de forma predecible. 007 se convierte en 7, 1E5 en 100000, 1.0 en 1. Códigos postales, números de pieza, números de teléfono y cadenas de versión mueren todos por la misma regla. El valor por defecto seguro es emitir cada valor como cadena y dejar que quien llame convierta lo que sabe, con la inferencia activada por columna en vez de una heurística para todo el archivo. La herramienta de CSV a JSON hace ese interruptor explícito exactamente por eso.

Una política por defecto que merece la pena declarar

Decisión Por defecto Por qué
Columnas Unión de todas las rutas hoja, ordenadas Muestrear el primer objeto tira campos sin avisar
Objetos anidados Ruta con puntos, separador configurable Las claves pueden contener legalmente el separador
Arrays JSON en una celda La única política reversible. Expande cuando el array es el registro
Contenedores vacíos [] y {} literalmente Distinguibles de null y de la cadena vacía
Nulos Celda vacía, documentado O un centinela cuando la distinción soporta peso
Números Texto fuente tal cual Nunca los hagas pasar por un float a la salida
Fin de línea CRLF RFC 4180, y LF rompe más lectores que CRLF
BOM Desactivado, con interruptor para Excel Correcto para tuberías, incorrecto para Excel, así que deja que el usuario diga cuál
Caracteres de fórmula Prefijados solo en la ruta de Excel Prefijar cambia los datos, así que no debería ocurrir en silencio

Estas no son las únicas respuestas defendibles. La cuestión es que todo conversor toma las nueve decisiones te lo diga o no, y solo los que te lo dicen son de fiar con un payload demasiado grande para revisarlo a ojo. El aplanador muestra el conjunto de rutas antes de que te comprometas, y la vista de tabla muestra el rectángulo que estás a punto de obtener.