Indice / Repaso: archivos y errores / 1.4
JSON con el módulo json
JSON trae los tipos puestos - números, booleanos, listas, objetos anidados - y por eso parece más fácil que el CSV. Lo que trae de nuevo es que cada registro puede tener una forma distinta.
Para qué sirve
Es el formato de casi todo lo que viaja por una red: una API, un archivo de
configuración, un log estructurado. En esta materia aparece en cinco datasets, y en la
lección 7.2 pandas lo va a aplanar automáticamente con
json_normalize.
Antes de eso conviene ver la estructura sin aplanar, porque el aplanado toma decisiones - qué pasa con una clave que falta, con un objeto anidado, con una lista - y esas decisiones solo se entienden si ya viste la forma original.
ELI5Explicado facil
Un CSV es una planilla: todas las filas tienen las mismas columnas, se cumpla o no con contenido.
Un JSON es más bien una carpeta de fichas. Cada ficha trae los campos que quiso traer, y algunas tienen fichas adentro. Es más expresivo, y por eso mismo hay que preguntar por cada campo antes de usarlo.
Leer
import json
with open("usuarios_perfiles.json", encoding="utf-8") as f:
datos = json.load(f)
print("la raiz es un", type(datos).__name__, "con claves", list(datos))
usuarios = datos["usuarios"]
print("usuarios:", len(usuarios))
primero = usuarios[0]
print()
print("un usuario:")
for clave, valor in primero.items():
print(f" {clave:<14} {type(valor).__name__:<5} {valor!r:.46}")
print()
print("Los tipos ya vienen convertidos, a diferencia del CSV:")
print(" edad ->", type(primero["edad"]).__name__, primero["edad"])
print(" activo ->", type(primero["activo"]).__name__, primero["activo"])
print()
print("Y se navega como cualquier dict o lista de Python:")
print(" preferencias['tema'] ->", primero["preferencias"]["tema"])
print(" habilidades[0] ->", primero["habilidades"][0])
print(" cuantas habilidades ->", len(primero["habilidades"]))
print()
print("Correspondencia de tipos JSON -> Python:")
muestra = json.loads('{"a": 1, "b": 2.5, "c": "texto", "d": true,'
' "e": null, "f": [1, 2], "g": {"h": 1}}')
for clave, valor in muestra.items():
print(f" {clave} -> {type(valor).__name__}")
print(" ojo: null se convierte en None, no en '' ni en 0.")
la raiz es un dict con claves ['usuarios']
usuarios: 15
un usuario:
id int 1
nombre str 'Olivia Medina'
edad int 19
activo bool True
habilidades list ['Power BI', 'JavaScript', 'Git', 'Estadística
preferencias dict {'tema': 'oscuro', 'idioma': 'es', 'notificaci
Los tipos ya vienen convertidos, a diferencia del CSV:
edad -> int 19
activo -> bool True
Y se navega como cualquier dict o lista de Python:
preferencias['tema'] -> oscuro
habilidades[0] -> Power BI
cuantas habilidades -> 5
Correspondencia de tipos JSON -> Python:
a -> int
b -> float
c -> str
d -> bool
e -> NoneType
f -> list
g -> dict
ojo: null se convierte en None, no en '' ni en 0.
| JSON | Python | Ojo con |
|---|---|---|
{ } | dict | Las claves siempre son str |
[ ] | list | - |
"texto" | str | Comillas dobles, siempre |
12 / 1.5 | int / float | El punto decide cuál de los dos |
true / false | True / False | Minúscula en JSON |
null | None | No es 0 ni "" |
| (no existe) | fecha | Viaja como texto y se reconstruye a mano |
La ventaja frente al CSV es real: edad llega como int y
activo como bool, sin conversión y sin
try/except. Lo que hay que revisar es otra cosa: si el campo
está.
Escribir
import datetime
import json
registro = {
"titulo": "Programación en Ñandú",
"anio": 2026,
"precio": 57.24,
"agotado": False,
"autor": None,
"temas": ["búsqueda", "árboles"],
}
print("por defecto, ensure_ascii=True escapa todo lo no ASCII:")
print(" ", json.dumps(registro))
print()
print("con ensure_ascii=False el archivo queda legible:")
print(" ", json.dumps(registro, ensure_ascii=False))
print()
print("y con indent= queda legible para una persona:")
print(json.dumps(registro, ensure_ascii=False, indent=2))
print()
print("Las dos formas se leen igual: el escape es JSON valido.")
print(" vuelven iguales ->",
json.loads(json.dumps(registro)) == json.loads(
json.dumps(registro, ensure_ascii=False)))
print()
print("JSON tiene menos tipos que Python. Ida y vuelta no siempre vuelve:")
mixto = {"tupla": (1, 2), 10: "clave numerica"}
ida = json.dumps(mixto)
print(" antes :", mixto)
print(" ida :", ida)
print(" vuelta:", json.loads(ida))
print(" la tupla volvio como list y la clave 10 como '10'.")
print()
print("Y hay tipos que directamente no puede escribir:")
for valor in (datetime.date(2026, 8, 28), {1, 2, 3}, 3 + 4j):
try:
json.dumps(valor)
except TypeError as error:
print(f" {type(valor).__name__:<8} -> TypeError: {error}")
print()
# default= recibe lo que json no sabe convertir y devuelve algo que si.
print("default= resuelve el caso de las fechas:")
con_fecha = {"alta": datetime.date(2026, 8, 28)}
print(" ", json.dumps(con_fecha, default=str))
print(" ...pero al leerlo de vuelta es un str: la fecha hay que")
print(" reconstruirla a mano. JSON no tiene tipo fecha.")
por defecto, ensure_ascii=True escapa todo lo no ASCII:
{"titulo": "Programaci\u00f3n en \u00d1and\u00fa", "anio": 2026, "precio": 57.24, "agotado": false, "autor": null, "temas": ["b\u00fasqueda", "\u00e1rboles"]}
con ensure_ascii=False el archivo queda legible:
{"titulo": "Programación en Ñandú", "anio": 2026, "precio": 57.24, "agotado": false, "autor": null, "temas": ["búsqueda", "árboles"]}
y con indent= queda legible para una persona:
{
"titulo": "Programación en Ñandú",
"anio": 2026,
"precio": 57.24,
"agotado": false,
"autor": null,
"temas": [
"búsqueda",
"árboles"
]
}
Las dos formas se leen igual: el escape es JSON valido.
vuelven iguales -> True
JSON tiene menos tipos que Python. Ida y vuelta no siempre vuelve:
antes : {'tupla': (1, 2), 10: 'clave numerica'}
ida : {"tupla": [1, 2], "10": "clave numerica"}
vuelta: {'tupla': [1, 2], '10': 'clave numerica'}
la tupla volvio como list y la clave 10 como '10'.
Y hay tipos que directamente no puede escribir:
date -> TypeError: Object of type date is not JSON serializable
set -> TypeError: Object of type set is not JSON serializable
complex -> TypeError: Object of type complex is not JSON serializable
default= resuelve el caso de las fechas:
{"alta": "2026-08-28"}
...pero al leerlo de vuelta es un str: la fecha hay que
reconstruirla a mano. JSON no tiene tipo fecha.
ensure_ascii=False y por qué siempre
Por defecto json.dumps escapa todo lo no ASCII, y
Programación en Ñandú se escribe
"Programaci\u00f3n en \u00d1and\u00fa". Es JSON válido y se lee
de vuelta idéntico, pero el archivo queda ilegible para una persona y cada acento pasa
a ocupar seis bytes en vez de dos. Con ensure_ascii=False el archivo dice
lo que dice - y por eso el generador de datasets de la materia lo usa.
JSON tiene menos tipos que Python. Una tupla vuelve como list; una
clave 10 vuelve como "10"; un set, un
complex o un date ni siquiera se pueden escribir
(TypeError: Object of type … is not JSON serializable).
default=str soluciona la escritura de las fechas, pero no la lectura:
vuelven como texto. JSON no tiene tipo fecha, y esa es la causa de la
mitad de los problemas de la lección 5.3.
Lo que hace difícil al JSON
import json
with open("usuarios_perfiles.json", encoding="utf-8") as f:
usuarios = json.load(f)["usuarios"]
# En un CSV todas las filas tienen las mismas columnas. En JSON cada
# objeto trae las claves que quiso traer, y hay que mirar nivel por nivel.
arriba = set()
for usuario in usuarios:
arriba |= set(usuario)
print("nivel de arriba:", sorted(arriba))
print(" todos los", len(usuarios), "usuarios traen las 6 ->",
all(set(u) == arriba for u in usuarios))
adentro = set()
for usuario in usuarios:
adentro |= set(usuario["preferencias"] or {})
print()
print("nivel de adentro (preferencias):", sorted(adentro))
for usuario in usuarios:
faltan = sorted(adentro - set(usuario["preferencias"] or {}))
if faltan:
print(" id", usuario["id"], "-> le faltan", faltan,
"|", usuario["preferencias"])
print()
print("Y los null sueltos, que no son lo mismo que una clave ausente:")
for usuario in usuarios:
for clave, valor in usuario.items():
if valor is None:
print(" id", usuario["id"], "->", clave, "= None")
print()
print("La diferencia importa porque get() con default no cubre el null:")
u3 = next(u for u in usuarios if u["id"] == 3)
print(" 'edad' in u3 ->", "edad" in u3)
print(" u3.get('edad', 0) ->", u3.get("edad", 0), " <- el default NO se usa")
print(" u3.get('telefono',0)->", u3.get("telefono", 0), " <- aca si")
print()
print("Por eso navegar anidado a lo bruto se rompe con el usuario 13:")
try:
for usuario in usuarios:
usuario["preferencias"]["tema"]
except TypeError as error:
print(" TypeError:", error)
print()
print("La forma que aguanta las dos situaciones:")
for usuario in usuarios:
tema = (usuario.get("preferencias") or {}).get("tema", "(sin definir)")
if tema == "(sin definir)" or usuario["id"] <= 2:
print(" id", usuario["id"], "->", tema)
print()
edades = [u["edad"] for u in usuarios if isinstance(u.get("edad"), int)]
print("edades usables:", len(edades), "de", len(usuarios),
"| promedio:", round(sum(edades) / len(edades), 1))
nivel de arriba: ['activo', 'edad', 'habilidades', 'id', 'nombre', 'preferencias']
todos los 15 usuarios traen las 6 -> True
nivel de adentro (preferencias): ['idioma', 'notificaciones', 'tema']
id 12 -> le faltan ['idioma'] | {'tema': 'oscuro', 'notificaciones': False}
id 13 -> le faltan ['idioma', 'notificaciones', 'tema'] | None
Y los null sueltos, que no son lo mismo que una clave ausente:
id 3 -> edad = None
id 11 -> activo = None
id 13 -> preferencias = None
La diferencia importa porque get() con default no cubre el null:
'edad' in u3 -> True
u3.get('edad', 0) -> None <- el default NO se usa
u3.get('telefono',0)-> 0 <- aca si
Por eso navegar anidado a lo bruto se rompe con el usuario 13:
TypeError: 'NoneType' object is not subscriptable
La forma que aguanta las dos situaciones:
id 1 -> oscuro
id 2 -> automatico
id 13 -> (sin definir)
edades usables: 14 de 15 | promedio: 46.6
Los 15 usuarios traen las 6 claves de arriba, así que a primera vista el archivo es homogéneo. Un nivel más abajo deja de serlo:
- Al usuario 12 le falta
idiomadentro depreferencias. - El usuario 13 tiene
preferenciasennull: la clave está, el objeto no. - Los usuarios 3 y 11 tienen
edadyactivoennull.
Por eso usuario["preferencias"]["tema"] revienta con
TypeError: 'NoneType' object is not subscriptable en el usuario 13, y por
eso la forma que aguanta es (usuario.get("preferencias") or {}).get("tema"):
el or {} cubre el null, el get cubre la clave
ausente.
.get(clave, default) no cubre el null
El default se usa cuando la clave no está. Si está y vale
None, get devuelve None tranquilamente:
u.get("edad", 0) da None, no 0. Son dos
ausencias distintas y hay que distinguirlas: «nunca me lo dijeron» no es lo mismo que
«me dijeron que no hay dato».
Cuando el archivo está roto
import json
# 1. json.loads lee de una cadena; json.load, de un archivo abierto.
# Es la unica diferencia, y la "s" es de "string".
print("loads sobre una cadena:", json.loads('{"a": 1}'))
print()
print("Los errores de sintaxis dicen linea y columna:")
malos = [
('{"a": 1,}', "coma de mas al final"),
("{'a': 1}", "comillas simples: JSON exige dobles"),
('{"a": 1, "a": 2}', "clave repetida (esto NO falla)"),
('{"a": NaN}', "NaN (Python lo acepta, otros lenguajes no)"),
("", "archivo vacio"),
]
for texto, comentario in malos:
try:
valor = json.loads(texto)
print(f" {texto!r:<20} -> ok: {valor!r} ({comentario})")
except json.JSONDecodeError as error:
print(f" {texto!r:<20} -> {error.msg}: linea {error.lineno},"
f" columna {error.colno} ({comentario})")
print()
print("La clave repetida gana la ultima y no avisa nunca. Y NaN e Infinity")
print("los acepta Python pero no son JSON estandar: escribirlos produce un")
print("archivo que otros lenguajes rechazan.")
print()
# 2. Un JSON valido puede traer, adentro, texto sin estructurar. El
# parseo entonces tiene DOS etapas, y la segunda es tuya.
with open("servidor_acceso_log.json", encoding="utf-8") as f:
log = json.load(f)
print("claves del log:", list(log))
print("lineas:", len(log["lineas"]))
print("el JSON esta perfecto, pero cada elemento es una cadena cruda:")
for linea in log["lineas"][:3]:
print(" ", repr(linea))
print()
partidas = [x.split() for x in log["lineas"]]
largos = {}
for campos in partidas:
largos[len(campos)] = largos.get(len(campos), 0) + 1
print("campos por linea:", largos)
print("json.load no valida NADA de eso: para el, son cadenas y listo.")
print("Esa segunda etapa de parseo es el tema de la leccion 1.5.")
loads sobre una cadena: {'a': 1}
Los errores de sintaxis dicen linea y columna:
'{"a": 1,}' -> Illegal trailing comma before end of object: linea 1, columna 8 (coma de mas al final)
"{'a': 1}" -> Expecting property name enclosed in double quotes: linea 1, columna 2 (comillas simples: JSON exige dobles)
'{"a": 1, "a": 2}' -> ok: {'a': 2} (clave repetida (esto NO falla))
'{"a": NaN}' -> ok: {'a': nan} (NaN (Python lo acepta, otros lenguajes no))
'' -> Expecting value: linea 1, columna 1 (archivo vacio)
La clave repetida gana la ultima y no avisa nunca. Y NaN e Infinity
los acepta Python pero no son JSON estandar: escribirlos produce un
archivo que otros lenguajes rechazan.
claves del log: ['origen', 'lineas']
lineas: 800
el JSON esta perfecto, pero cada elemento es una cadena cruda:
'2026-08-24T08:01:01 10.0.0.28 PUT /login 200 102'
'2026-08-24T08:01:12 10.0.0.31 DELETE /salir 200 515'
'2026-08-24T08:01:53 10.0.0.31 POST /salir 201 484'
campos por linea: {6: 796, 2: 1, 4: 1, 0: 1, 1: 1}
json.load no valida NADA de eso: para el, son cadenas y listo.
Esa segunda etapa de parseo es el tema de la leccion 1.5.
JSONDecodeError es una subclase de ValueError y trae
.lineno, .colno y .msg: dice exactamente dónde
está el problema, que es más de lo que ofrece cualquier otro formato. Pero hay tres
cosas que no son errores y conviene conocer:
| Texto | Qué pasa |
|---|---|
{"a": 1, "a": 2} | No falla: gana la última y no queda rastro de la primera |
{"a": NaN} | No falla en Python, pero no es JSON estándar: otros lenguajes lo rechazan |
{"a": 1,} | Sí falla - la coma final se permite en JavaScript, no en JSON |
Y el caso del log: el JSON es perfectamente válido y sus 800 elementos son cadenas
crudas. json.load no valida nada de lo que hay adentro de una cadena. El
parseo tiene dos etapas - la del formato y la del contenido - y la segunda
es siempre tuya. Es lo que la lección 1.5 convierte en las cuatro capas.
Dónde se rompe
Causa: el archivo está vacío, o no es JSON (una página de error HTML es el clásico cuando viene de una API).
Arreglo: mirar los primeros caracteres del archivo antes de parsearlo.
Causa: comillas simples. Es la sintaxis de un dict de
Python, no JSON.
Arreglo: JSON exige comillas dobles. Si el texto salió de un
print() de Python, no es JSON: hay que regenerarlo con
json.dumps.
Causa: encadenaste un acceso sobre un valor que vino en
null.
Arreglo: (d.get("k") or {}) en cada nivel que pueda faltar
o venir nulo.
Causa: le pasaste a dump un tipo que JSON no tiene.
Arreglo: default=str, o convertir explícitamente con
.isoformat() antes de escribir.
Causa: el default ensure_ascii=True.
Arreglo: ensure_ascii=False. El archivo anterior no está
mal, solo es ilegible.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
json.load(f) | Lee de un archivo abierto | Al leer |
json.loads(texto) | Lee de una cadena | Respuesta de una API, línea suelta |
json.dump(obj, f, ...) | Escribe a un archivo | Al escribir |
json.dumps(obj, ...) | Devuelve la cadena | Para imprimir o inspeccionar |
ensure_ascii=False | Deja los acentos legibles | Siempre |
indent=2 | Sangra la salida | Archivos que va a leer una persona |
default=str | Convierte lo que no sabe escribir | Fechas |
sort_keys=True | Ordena las claves | Para poder comparar dos salidas |
d.get("k") | None si no está | Clave opcional |
(d.get("k") or {}).get("j") | Aguanta ausente y null | Anidados |
"k" in d | Distingue ausente de null | Cuando la diferencia importa |
except json.JSONDecodeError as e: | e.lineno, e.colno, e.msg | Archivo posiblemente roto |