Saltar al contenido

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

Ejemplojson.load: del archivo a estructuras de Python
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.
Correspondencia de tipos
JSONPythonOjo con
{ }dictLas claves siempre son str
[ ]list-
"texto"strComillas dobles, siempre
12 / 1.5int / floatEl punto decide cuál de los dos
true / falseTrue / FalseMinúscula en JSON
nullNoneNo es 0 ni ""
(no existe)fechaViaja 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

Ejemplojson.dump, ensure_ascii y lo que NO sobrevive al viaje
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.

La ida y la vuelta no siempre devuelven lo mismo

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

EjemploClaves que faltan, claves que dicen null: no es lo mismo
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:

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

EjemploCuando el JSON no es JSON valido, y JSON que trae texto adentro
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:

TextoQué 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

json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

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.

JSONDecodeError: Expecting property name enclosed in double quotes

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.

TypeError: 'NoneType' object is not subscriptable

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.

TypeError: Object of type date is not JSON serializable

Causa: le pasaste a dump un tipo que JSON no tiene.

Arreglo: default=str, o convertir explícitamente con .isoformat() antes de escribir.

(sin error) los acentos quedaron como ñ en el archivo

Causa: el default ensure_ascii=True.

Arreglo: ensure_ascii=False. El archivo anterior no está mal, solo es ilegible.

Recetario

El módulo json
ExpresiónQué haceCuándo
json.load(f)Lee de un archivo abiertoAl leer
json.loads(texto)Lee de una cadenaRespuesta de una API, línea suelta
json.dump(obj, f, ...)Escribe a un archivoAl escribir
json.dumps(obj, ...)Devuelve la cadenaPara imprimir o inspeccionar
ensure_ascii=FalseDeja los acentos legiblesSiempre
indent=2Sangra la salidaArchivos que va a leer una persona
default=strConvierte lo que no sabe escribirFechas
sort_keys=TrueOrdena las clavesPara poder comparar dos salidas
d.get("k")None si no estáClave opcional
(d.get("k") or {}).get("j")Aguanta ausente y nullAnidados
"k" in dDistingue ausente de nullCuando la diferencia importa
except json.JSONDecodeError as e:e.lineno, e.colno, e.msgArchivo posiblemente roto

Ponete a prueba

1. En el archivo dice "edad": 19. Después de json.load, ¿de qué tipo es ese valor?Nivel 1 · Recordar
2. ¿Cuál es la diferencia entre json.load y json.loads?Nivel 1 · Recordar
3. El usuario 3 tiene "edad": null. ¿Qué devuelve usuario.get("edad", 0)?Nivel 2 · Predecir la salida
4. Escribís un dict con json.dump y lo volvés a leer. ¿Qué no vuelve igual?Nivel 2 · Predecir la salida
5. Necesitás el tema visual de cada usuario, sabiendo que preferencias puede venir en null y que puede faltar la clave tema. Las cuatro son sintaxis válida: ¿cuál conviene?Nivel 3 · Elegir entre dos casi correctas
6. Un archivo tiene {"puerto": 8080, "puerto": 99999}. ¿Qué hace json.load?Nivel 3 · Elegir entre dos casi correctas
7. Este código sobre servidor_acceso_log.json no levanta ninguna excepción y termina informando 800 accesos válidos, pero 4 líneas del archivo están rotas. ¿Por qué no las detectó?
with open(r, encoding="utf-8") as f:
    log = json.load(f)
print(len(log["lineas"]), "accesos validos")
Nivel 4 · Encontrar el defecto

Prácticas de esta subunidad