Saltar al contenido

Indice / Entrada y salida / 7.2

JSON con pandas

Un CSV es una tabla. Un JSON puede ser una tabla, o puede tener la tabla adentro, o tener listas adentro de cada fila. El primer paso no es leerlo: es mirarlo.

Para qué sirve

Casi todo lo que devuelve una API es JSON, y casi nunca tiene la forma de una tabla. Lo típico es un objeto con metadatos arriba y la lista de registros adentro de una clave, y cada registro con objetos y listas anidados.

pandas tiene dos herramientas: read_json, que sirve cuando el archivo ya es una tabla, y json_normalize, que aplana lo anidado. La segunda es la que se usa el 90 % de las veces.

ELI5Explicado facil

Un CSV es una planilla: filas y columnas, y listo.

Un JSON es más bien una caja con cajas adentro. Puede haber una planilla en el fondo, pero primero hay que abrir las cajas y ver dónde está. Y a veces adentro de una fila hay otra lista, que no entra en una celda.

El primer paso es mirar

EjemploUn JSON no siempre es una tabla: primero hay que encontrarla
import json

import pandas as pd

with open("eventos_usuarios.json", encoding="utf-8") as f:
    crudo = json.load(f)

print("el archivo es un", type(crudo).__name__, "con las claves:", list(crudo))
print("origen:", crudo["origen"])
print("eventos:", len(crudo["eventos"]), "elementos")

print()
print("la tabla esta ADENTRO, en la clave 'eventos':")
eventos = pd.json_normalize(crudo["eventos"])
print(eventos.shape, list(eventos.columns))
print(eventos.head(3).to_string(index=False))

print()
print("read_json directo sobre este archivo no encuentra la tabla:")
directo = pd.read_json("eventos_usuarios.json")
print(directo.shape, list(directo.columns))
print("Devolvio 2 columnas porque tomo las dos claves de primer nivel.")
print()
print("Con JSON, el primer paso es MIRAR la estructura. json.load y un")
print("print alcanzan, y evitan pelearse a ciegas con read_json.")
el archivo es un dict con las claves: ['origen', 'eventos']
origen: web-analytics
eventos: 600 elementos

la tabla esta ADENTRO, en la clave 'eventos':
(600, 5) ['usuario', 'fecha', 'tipo', 'pagina', 'importe']
usuario               fecha      tipo    pagina  importe
    u10 2026-08-24T15:11:00 page_view  /ofertas      NaN
    u12 2026-08-24T13:35:00 page_view    /ayuda      NaN
    u11 2026-08-24T08:14:00 page_view /checkout      NaN

read_json directo sobre este archivo no encuentra la tabla:
(600, 2) ['origen', 'eventos']
Devolvio 2 columnas porque tomo las dos claves de primer nivel.

Con JSON, el primer paso es MIRAR la estructura. json.load y un
print alcanzan, y evitan pelearse a ciegas con read_json.
read_json sobre un archivo anidado devuelve algo que no sirve

Con un objeto que tiene origen y eventos, read_json devuelve un DataFrame de dos columnas, con la lista entera de eventos repetida en cada fila. No da error: devuelve una tabla que no es la tabla.

Por eso el orden es: json.load, mirar las claves, y recién ahí json_normalize sobre la parte que importa.

json_normalize

Ejemplojson_normalize aplana lo anidado con un punto
import json

import pandas as pd

with open("usuarios_perfiles.json", encoding="utf-8") as f:
    crudo = json.load(f)

perfiles = pd.json_normalize(crudo["usuarios"])
print("forma:", perfiles.shape)
print("columnas:", list(perfiles.columns))

print()
print("Las claves de 'preferencias' quedaron como preferencias.tema,")
print("preferencias.idioma y preferencias.notificaciones.")

print()
print(perfiles[["id", "nombre", "edad", "preferencias.tema",
                "preferencias.idioma"]].head(5).to_string(index=False))

print()
print("ausentes por columna:")
print(perfiles.isna().sum().to_string())

print()
print("No todos los usuarios traen las mismas claves: a uno le falta el")
print("idioma. json_normalize no se queja, pone NaN y sigue, que es lo que")
print("uno quiere: la alternativa seria fallar por un campo opcional.")
forma: (15, 9)
columnas: ['id', 'nombre', 'edad', 'activo', 'habilidades', 'preferencias.tema', 'preferencias.idioma', 'preferencias.notificaciones', 'preferencias']

Las claves de 'preferencias' quedaron como preferencias.tema,
preferencias.idioma y preferencias.notificaciones.

 id         nombre  edad preferencias.tema preferencias.idioma
  1  Olivia Medina  19.0            oscuro                  es
  2    Belén Gómez  37.0        automatico                  es
  3     Hugo Ángel   NaN            oscuro                  es
  4 Nicolás Acosta  61.0            oscuro                  es
  5    Irene Ramos  47.0        automatico                  es

ausentes por columna:
id                              0
nombre                          0
edad                            1
activo                          1
habilidades                     0
preferencias.tema               1
preferencias.idioma             2
preferencias.notificaciones     1
preferencias                   15

No todos los usuarios traen las mismas claves: a uno le falta el
idioma. json_normalize no se queja, pone NaN y sigue, que es lo que
uno quiere: la alternativa seria fallar por un campo opcional.

Los objetos anidados se aplanan con un punto: preferencias.tema, preferencias.idioma. Y cuando a un registro le falta una clave que otros sí tienen, queda NaN - sin avisar, que es lo correcto: un campo opcional no debería romper la lectura.

La columna fantasma

EjemploLa columna fantasma que aparece cuando un anidado es null
import json

import pandas as pd

with open("usuarios_perfiles.json", encoding="utf-8") as f:
    crudo = json.load(f)

perfiles = pd.json_normalize(crudo["usuarios"])

print("columnas:", list(perfiles.columns))
print()
print("Hay preferencias.tema, preferencias.idioma, preferencias.notificaciones")
print("Y ADEMAS una columna que se llama 'preferencias' a secas:")
print("valores no nulos en 'preferencias':",
      int(perfiles["preferencias"].notna().sum()), "de", len(perfiles))

print()
# De donde sale: un usuario tiene preferencias: null en vez de un objeto.
sin_pref = [u["id"] for u in crudo["usuarios"] if u.get("preferencias") is None]
print("usuarios con preferencias = null:", sin_pref)
print("Como ese valor no es un diccionario, json_normalize no lo puede")
print("aplanar y lo deja en una columna aparte, que queda toda vacia.")

print()
util = perfiles.drop(columns=["preferencias"])
print("despues de sacarla:", list(util.columns))

print()
print("Es un caso que hay que MIRAR, no un error: ese usuario existe y no")
print("tiene preferencias cargadas. Lo que no sirve es la columna vacia.")
columnas: ['id', 'nombre', 'edad', 'activo', 'habilidades', 'preferencias.tema', 'preferencias.idioma', 'preferencias.notificaciones', 'preferencias']

Hay preferencias.tema, preferencias.idioma, preferencias.notificaciones
Y ADEMAS una columna que se llama 'preferencias' a secas:
valores no nulos en 'preferencias': 0 de 15

usuarios con preferencias = null: [13]
Como ese valor no es un diccionario, json_normalize no lo puede
aplanar y lo deja en una columna aparte, que queda toda vacia.

despues de sacarla: ['id', 'nombre', 'edad', 'activo', 'habilidades', 'preferencias.tema', 'preferencias.idioma', 'preferencias.notificaciones']

Es un caso que hay que MIRAR, no un error: ese usuario existe y no
tiene preferencias cargadas. Lo que no sirve es la columna vacia.
Una columna con 0 valores no nulos es una señal, no un error

El usuario 13 tiene "preferencias": null en vez de un objeto. Como null no es un diccionario, json_normalize no lo puede aplanar y lo deja en una columna aparte que queda vacía para todos.

El dato es real y hay que decidir qué hacer con él. Lo que no sirve es la columna, que se saca con drop.

Listas anidadas

Ejemplorecord_path: una lista anidada se convierte en varias filas
import json

import pandas as pd

with open("usuarios_perfiles.json", encoding="utf-8") as f:
    crudo = json.load(f)

# record_path dice cual es la lista que hay que explotar; meta, que datos
# del nivel de arriba hay que arrastrar a cada fila.
habilidades = pd.json_normalize(
    crudo["usuarios"],
    record_path="habilidades",
    meta=["id", "nombre"],
)
habilidades = habilidades.rename(columns={0: "habilidad"})

print("forma:", habilidades.shape)
print(habilidades.head(6).to_string(index=False))

print()
print("usuarios:", habilidades["id"].nunique())
print("habilidades distintas:", habilidades["habilidad"].nunique())

print()
print("las mas frecuentes:")
print(habilidades["habilidad"].value_counts().head(5).to_string())

print()
print("Lo mismo se puede hacer con explode() si la lista ya esta en una")
print("columna, pero record_path lo resuelve al leer, en un solo paso.")
forma: (50, 3)
  habilidad id        nombre
   Power BI  1 Olivia Medina
 JavaScript  1 Olivia Medina
        Git  1 Olivia Medina
Estadística  1 Olivia Medina
      Linux  1 Olivia Medina
        Git  2   Belén Gómez

usuarios: 14
habilidades distintas: 10

las mas frecuentes:
habilidad
Linux          11
Git             6
Estadística     5
Python          5
Excel           5

Lo mismo se puede hacer con explode() si la lista ya esta en una
columna, pero record_path lo resuelve al leer, en un solo paso.

record_path dice cuál es la lista que hay que explotar y meta qué datos del nivel de arriba arrastrar a cada fila. 15 usuarios con sus habilidades se convierten en 50 filas de una fila por habilidad - que es el formato largo de la lección 6.5, otra vez.

Fijate en un detalle: aparecen 14 usuarios, no 15. Uno tiene la lista de habilidades vacía, así que no aporta ninguna fila y desaparece del resultado. Es el mismo modo de falla que un inner join.

Escribir

Ejemploto_json: el orient decide la forma del archivo
import pandas as pd

tabla = pd.DataFrame({
    "sku": ["A1", "A2"],
    "nombre": ["Teclado ñandú", "Ratón"],
    "precio": [10.5, 20.0],
})

print('orient="records" (una lista de objetos, el mas usado):')
print(tabla.to_json(orient="records", force_ascii=False, indent=2))

print()
print('orient="columns" (el default: un objeto por columna):')
print(tabla.to_json(orient="columns", force_ascii=False))

print()
print('orient="split" (columnas, indice y datos por separado):')
print(tabla.to_json(orient="split", force_ascii=False))

print()
print("Y sin force_ascii=False:")
print(tabla.to_json(orient="records"))
print()
print("Los acentos salieron como \\u escapes. Es JSON valido y lo lee")
print("cualquiera, pero el archivo deja de ser legible para una persona.")
print("En pandas el parametro se llama force_ascii; en el modulo json,")
print("ensure_ascii. Hacen lo mismo con nombres distintos.")
orient="records" (una lista de objetos, el mas usado):
[
  {
    "sku":"A1",
    "nombre":"Teclado ñandú",
    "precio":10.5
  },
  {
    "sku":"A2",
    "nombre":"Ratón",
    "precio":20.0
  }
]

orient="columns" (el default: un objeto por columna):
{"sku":{"0":"A1","1":"A2"},"nombre":{"0":"Teclado ñandú","1":"Ratón"},"precio":{"0":10.5,"1":20.0}}

orient="split" (columnas, indice y datos por separado):
{"columns":["sku","nombre","precio"],"index":[0,1],"data":[["A1","Teclado ñandú",10.5],["A2","Ratón",20.0]]}

Y sin force_ascii=False:
[{"sku":"A1","nombre":"Teclado \u00f1and\u00fa","precio":10.5},{"sku":"A2","nombre":"Rat\u00f3n","precio":20.0}]

Los acentos salieron como \u escapes. Es JSON valido y lo lee
cualquiera, pero el archivo deja de ser legible para una persona.
En pandas el parametro se llama force_ascii; en el modulo json,
ensure_ascii. Hacen lo mismo con nombres distintos.
Los orient que se usan
ValorFormaCuándo
"records"Una lista de objetosEl más usado: es lo que espera casi cualquier API
"columns"Un objeto por columnaEl default, y casi nunca lo que se quiere
"split"Columnas, índice y datos por separadoCompacto, para ida y vuelta con pandas
"index"Un objeto por fila, con la etiqueta como claveCuando el índice significa algo
force_ascii=False, siempre

Sin él, Teclado ñandú se escribe Teclado ñandú. Es JSON válido y cualquier programa lo lee bien, pero el archivo deja de ser legible para una persona - y estos archivos los abre gente.

Ojo con el nombre: en pandas es force_ascii; en el módulo json de la biblioteca estándar es ensure_ascii. Hacen lo mismo y se llaman distinto.

Dónde se rompe

(sin error) read_json devuelve 2 columnas y esperabas 5

Causa: el archivo tiene metadatos arriba y la tabla adentro de una clave.

Arreglo: json.load, mirar list(crudo), y json_normalize(crudo["la_clave"]).

ValueError: Trailing data

Causa: el archivo es JSON Lines: un objeto por línea, sin corchetes ni comas entre ellos.

Arreglo: read_json(..., lines=True).

(sin error) una columna quedó con listas adentro

Causa: el JSON tiene una lista anidada y json_normalize la dejó como está: una lista de Python en cada celda.

Arreglo: record_path al leer, o explode() después. Una celda con una lista adentro no se puede agrupar ni filtrar.

(sin error) un registro desapareció del resultado

Causa: con record_path, un registro cuya lista está vacía no aporta ninguna fila.

Arreglo: comparar df["id"].nunique() con la cantidad de registros del JSON. Si no coinciden, hay listas vacías.

(sin error) los acentos salieron como ñ

Causa: falta force_ascii=False en to_json.

Arreglo: agregarlo. El archivo sigue siendo JSON válido de las dos formas; la diferencia es si alguien lo puede leer.

Recetario

JSON con pandas
ExpresiónQué haceCuándo
json.load(f)Devuelve dicts y listas de PythonSiempre primero, para mirar
pd.json_normalize(lista)Aplana los objetos anidadosEl caso normal
record_path="items"Explota una lista anidadaUna fila por elemento
meta=["id", "nombre"]Arrastra datos del nivel de arribaJunto con record_path
sep="_"Otro separador para los nombres aplanadosSi el punto molesta
pd.read_json(f)Lee un JSON que ya es una tablaCuando la estructura es plana
read_json(..., lines=True)JSON LinesValueError: Trailing data
df.explode("c")Una fila por elemento de la listaSi la lista ya está en una columna
to_json(orient="records", force_ascii=False, indent=2)El archivo que espera casi todo el mundoAl escribir

Ponete a prueba

1. ¿Cual es el primer paso al enfrentarse a un archivo JSON desconocido?Nivel 1 · Recordar
2. ¿Como quedan los nombres de las columnas al aplanar un objeto anidado preferencias?Nivel 1 · Recordar
3. Despues de json_normalize aparece una columna preferencias con 0 valores no nulos, ademas de las preferencias.*. ¿Por que?Nivel 2 · Predecir la salida
4. ¿Que hacen record_path y meta juntos?Nivel 2 · Predecir la salida
5. Con record_path="habilidades" sobre 15 usuarios, el resultado tiene 14 usuarios distintos. ¿Que paso con el que falta?Nivel 3 · Elegir entre dos casi correctas
6. ¿Que orient conviene para entregarle un JSON a otro sistema?Nivel 3 · Elegir entre dos casi correctas
7. Este codigo escribe un JSON que un compañero no puede leer bien: los nombres salen como Rat\u00f3n. ¿Que falta?
resumen.to_json("salida.json", orient="records", indent=2)
Nivel 4 · Encontrar el defecto

Prácticas de esta lección