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
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
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
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.
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
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
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.
| Valor | Forma | Cuándo |
|---|---|---|
"records" | Una lista de objetos | El más usado: es lo que espera casi cualquier API |
"columns" | Un objeto por columna | El default, y casi nunca lo que se quiere |
"split" | Columnas, índice y datos por separado | Compacto, para ida y vuelta con pandas |
"index" | Un objeto por fila, con la etiqueta como clave | Cuando 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
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"]).
Causa: el archivo es JSON Lines: un objeto por línea, sin corchetes ni comas entre ellos.
Arreglo: read_json(..., lines=True).
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.
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.
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
| Expresión | Qué hace | Cuándo |
|---|---|---|
json.load(f) | Devuelve dicts y listas de Python | Siempre primero, para mirar |
pd.json_normalize(lista) | Aplana los objetos anidados | El caso normal |
record_path="items" | Explota una lista anidada | Una fila por elemento |
meta=["id", "nombre"] | Arrastra datos del nivel de arriba | Junto con record_path |
sep="_" | Otro separador para los nombres aplanados | Si el punto molesta |
pd.read_json(f) | Lee un JSON que ya es una tabla | Cuando la estructura es plana |
read_json(..., lines=True) | JSON Lines | ValueError: Trailing data |
df.explode("c") | Una fila por elemento de la lista | Si la lista ya está en una columna |
to_json(orient="records", force_ascii=False, indent=2) | El archivo que espera casi todo el mundo | Al escribir |