CSV dejó a Papyrus hablando el idioma de las tablas, pero con un techo a la vista: las tablas son planas. Los socios de la tienda no lo son — Luis (LUIS-001) tiene un nombre, un código y una lista de compras, cada una con su fecha y su importe. Meter una lista dentro de una celda de CSV es forzar el formato; para estructuras anidadas existe JSON (JavaScript Object Notation), el formato de texto en que hoy se escriben las configuraciones de las aplicaciones y las respuestas de casi todas las APIs web. En esta lección aprenderás las cuatro funciones del módulo json, la tabla de equivalencias entre tipos Python y JSON (y qué se queda fuera), y llevarás el hilo de Papyrus dos pasos más allá: el catálogo de dataclasses viajará a JSON con asdict() y volverá con Libro(**datos), y la tienda estrenará su config.json.

Contenido

  1. Qué es JSON y dónde vive
  2. Las cuatro funciones del módulo json: dump, load, dumps, loads
  3. Tipos Python ↔ JSON: la tabla de equivalencias
  4. indent y ensure_ascii=False: JSON legible y con tildes
  5. El catálogo en JSON: asdict() a la ida, Libro(**datos) a la vuelta
  6. Anidamiento: los socios de Papyrus con sus compras
  7. JSON vs CSV: tabla de criterio
  8. config.json: las constantes de Papyrus salen del código
  9. Una mención a pickle
  10. Errores comunes y consejos
  11. Ejercicios con soluciones

Qué es JSON y dónde vive

JSON es un formato de texto plano para representar datos estructurados. Su sintaxis te resultará sospechosamente familiar:

{
    "titulo": "La Odisea",
    "precio": 12.5,
    "stock": 4,
    "disponible": true,
    "etiquetas": ["clásicos", "poesía épica"]
}

Parece un diccionario de Python con una lista dentro — y casi lo es (las diferencias, en la tabla de equivalencias). Nació en el mundo JavaScript, pero hoy es neutral y omnipresente. Lo encontrarás en tres hábitats:

  • APIs web: cuando en el módulo 10 tu programa pida datos a un servidor (o los sirva con Flask), las respuestas serán JSON.
  • Archivos de configuración: preferencias de aplicaciones, ajustes de editores (el settings.json de VS Code, sin ir más lejos).
  • Intercambio y almacenamiento de datos estructurados: justo lo que Papyrus necesita para catálogo y socios.

A diferencia de CSV, JSON anida sin límite (objetos dentro de listas dentro de objetos) y conserva los tipos básicos: un 12.5 vuelve como número, no como la cadena "12.5". El peaje de conversión que pagamos en 06-01 y 06-02 casi desaparece.

Las cuatro funciones del módulo json: dump, load, dumps, loads

Toda la lección gira sobre cuatro funciones de la biblioteca estándar. El truco nemotécnico: la s final significa string — las versiones con s trabajan con cadenas en memoria; las versiones sin s, con archivos abiertos.

Función Dirección Trabaja con Uso típico
json.dump(datos, f) Python → JSON Archivo abierto para escribir Guardar el catálogo a catalogo.json
json.load(f) JSON → Python Archivo abierto para leer Cargar config.json al arrancar
json.dumps(datos) Python → JSON Devuelve una cadena Enviar datos a una API, imprimir para depurar
json.loads(cadena) JSON → Python Recibe una cadena Interpretar la respuesta de una API

Las cuatro en acción:

import json

libro = {"titulo": "Hamlet", "precio": 9.95, "stock": 6}

# dumps/loads: cadena en memoria (fíjate: sin tocar el disco)
texto = json.dumps(libro)
print(texto)                       # {"titulo": "Hamlet", "precio": 9.95, "stock": 6}
print(type(texto))                 # <class 'str'>
copia = json.loads(texto)
print(copia["precio"] + 1)         # 10.95 — ¡vuelve como float, no como "9.95"!

# dump/load: directo a archivo — las reglas de 06-01 siguen vigentes (with + utf-8)
with open("libro.json", "w", encoding="utf-8") as f:
    json.dump(libro, f)

with open("libro.json", "r", encoding="utf-8") as f:
    recuperado = json.load(f)
print(recuperado == libro)         # True — round-trip perfecto, tipos incluidos

Ese copia["precio"] + 1 marca la diferencia con todo lo anterior: en texto plano y CSV, los números volvían como cadenas y convertías a mano; JSON recuerda que 9.95 era un número. (El newline="" de 06-02 era una peculiaridad del módulo csv; con json no se usa.)

Tipos Python ↔ JSON: la tabla de equivalencias

La traducción entre ambos mundos está definida tipo a tipo:

Python (al escribir) JSON Python (al leer de vuelta)
dict objeto {...} dict
list, tuple array [...] list (¡la tupla no vuelve como tupla!)
str string "..." str
int número int
float número float
True / False true / false True / False
None null None

Y las letras pequeñas del contrato, que conviene leer:

  • Las claves de un objeto JSON son siempre cadenas. Un dict Python con claves enteras ({1: "a"}) se escribe como {"1": "a"} y al volver las claves son str. Si usas claves no textuales, el round-trip no es fiel.
  • Las tuplas se degradan a listas: ("a", "b")["a", "b"]["a", "b"]. JSON no tiene tuplas.
  • Lo que NO se serializa: set, datetime/date, bytes y — atención — tus objetos: json.dumps(Libro("Hamlet", 9.95, 6)) lanza TypeError: Object of type Libro is not JSON serializable. La estrategia estándar es convertir a tipos básicos antes (un setlist, un date"2026-07-13" con str() o isoformat(), y una dataclass → dict, como verás en dos secciones).

Esa última limitación no es un defecto: JSON es deliberadamente pequeño para que cualquier lenguaje pueda leerlo. Tus objetos son de Python; sus datos, universales.

indent y ensure_ascii=False: JSON legible y con tildes

Por defecto, dump/dumps escriben todo en una línea y escapan los caracteres no ASCII. El resultado funciona, pero es ilegible para humanos:

config = {"nombre_tienda": "Papyrus", "categoría": "librería"}
print(json.dumps(config))
# {"nombre_tienda": "Papyrus", "categoría": "librería"}   ← ¿y mis tildes?

Dos argumentos lo arreglan:

  • indent=4: formatea con saltos de línea y sangría de 4 espacios — imprescindible en archivos que una persona vaya a leer o editar (configuraciones).
  • ensure_ascii=False: escribe í en lugar de í. El escape es una protección arcaica para sistemas que no entienden UTF-8; como nosotros abrimos siempre con encoding="utf-8" (06-01), podemos desactivarla y tener tildes de verdad en el archivo.
print(json.dumps(config, indent=4, ensure_ascii=False))
{
    "nombre_tienda": "Papyrus",
    "categoría": "librería"
}

Regla práctica del curso: en archivos JSON destinados a humanos, indent=4, ensure_ascii=False siempre. En JSON que viaja entre programas (APIs), la versión compacta ahorra bytes y nadie la lee.

El catálogo en JSON: asdict() a la ida, Libro(**datos) a la vuelta

El obstáculo — "tus objetos no se serializan" — tiene una solución elegante para dataclasses, y viene en la propia biblioteca: dataclasses.asdict() convierte una dataclass (recursivamente, campos anidados incluidos) en un diccionario de tipos básicos, que JSON sí entiende:

from dataclasses import asdict

odisea = Libro("La Odisea", 12.50, 4)     # la dataclass canónica de 05-06
print(asdict(odisea))                     # {'titulo': 'La Odisea', 'precio': 12.5, 'stock': 4}

Y para el viaje de vuelta, el desempaquetado de diccionarios con ** (03-02): Libro(**{"titulo": "La Odisea", "precio": 12.5, "stock": 4}) equivale a Libro(titulo="La Odisea", precio=12.5, stock=4). Como los campos de la dataclass se llaman igual que las claves del dict, encajan solos. El round-trip completo del catálogo queda así:

import json
from pathlib import Path
from dataclasses import asdict

def guardar_catalogo_json(catalogo, ruta="catalogo.json"):
    """dict[str, Libro] → lista de dicts → JSON."""
    datos = [asdict(libro) for libro in catalogo.values()]
    with open(ruta, "w", encoding="utf-8") as f:
        json.dump(datos, f, indent=4, ensure_ascii=False)

def cargar_catalogo_json(ruta="catalogo.json"):
    """JSON → lista de dicts → dict[str, Libro]. Sin conversiones str→float: JSON recuerda."""
    if not Path(ruta).exists():          # el paliativo de siempre; módulo 7 al rescate pronto
        return {}
    with open(ruta, "r", encoding="utf-8") as f:
        datos = json.load(f)
    return {normalizar_titulo(d["titulo"]): Libro(**d) for d in datos}

guardar_catalogo_json(catalogo)
recargado = cargar_catalogo_json()
print(recargado == catalogo)                            # True
print(recargado["hamlet"].precio_final(socio=True))     # 9.83 — la cifra de siempre

Compara cargar_catalogo_json con el cargar_catalogo de 06-02: han desaparecido float(fila["precio"]) e int(fila["stock"]). JSON devolvió cada tipo intacto y Libro(**d) reconstruyó el objeto en una expresión — con __post_init__ validando de regalo, igual que siempre. El catalogo.json resultante es una lista de objetos:

[
    {
        "titulo": "La Odisea",
        "precio": 12.5,
        "stock": 4
    },
    {
        "titulo": "Hamlet",
        "precio": 9.95,
        "stock": 6
    }
]

(Nota al margen: guardamos catalogo.values() como lista y reconstruimos las claves normalizadas al cargar, igual que en 06-02 — la clave es derivable, no hace falta almacenarla.)

Anidamiento: los socios de Papyrus con sus compras

Aquí JSON juega en una liga a la que CSV no llega. Cada socio es un objeto con una lista de compras dentro, y cada compra es a su vez un objeto — la estructura calca las LineaVenta de 05-06:

socios = [
    {
        "nombre": "Luis", "codigo": "LUIS-001",
        "compras": [
            {"fecha": "2026-07-13", "titulo": "Hamlet", "importe": 9.83},
            {"fecha": "2026-07-11", "titulo": "La Odisea", "importe": 12.35},
        ],
    },
    {
        "nombre": "Marta", "codigo": "MARTA-002",
        "compras": [
            {"fecha": "2026-07-12", "titulo": "El Quijote", "importe": 15.71},
        ],
    },
    {"nombre": "Pau", "codigo": "PAU-003", "compras": []},
]

with open("socios.json", "w", encoding="utf-8") as f:
    json.dump(socios, f, indent=4, ensure_ascii=False)

Y al cargar, navegas la estructura con la sintaxis de siempre — índices y claves encadenados (04-01/04-03), o mejor, bucles y comprensiones:

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

print(socios[0]["compras"][1]["titulo"])       # La Odisea

gasto_por_socio = {s["codigo"]: round(sum(c["importe"] for c in s["compras"]), 2)
                   for s in socios}
print(gasto_por_socio)    # {'LUIS-001': 22.18, 'MARTA-002': 15.71, 'PAU-003': 0}

Intenta imaginar este dato en CSV: o una fila por compra repitiendo los datos del socio, o listas embutidas en una celda. JSON lo expresa tal y como lo piensas.

JSON vs CSV: tabla de criterio

Dos lecciones, dos formatos de texto. El criterio para elegir:

CSV (06-02) JSON (esta lección)
Forma de los datos Tabla plana: filas × columnas Árbol: anidamiento libre
Tipos Todo vuelve como str Conserva número/booleano/null
Lo abre Excel/Sheets Sí, directamente No (es para programas)
Tamaño con muchas filas Compacto (cabecera una vez) Verboso (claves repetidas por objeto)
Hábitat natural Exportaciones, hojas de cálculo, datos masivos por filas APIs, configuraciones, estructuras anidadas
En Papyrus ventas.csv (histórico tabular que Ana abre en Excel) config.json, socios.json (estructura y tipos)

Regla rápida: ¿los datos son una tabla que un humano querrá abrir en una hoja de cálculo? CSV. ¿Tienen estructura, anidamiento o tipos que importan? JSON. El catálogo de Papyrus, siendo tabular, vive cómodo en ambos — lo hemos guardado en los dos formatos precisamente para que compares el código.

config.json: las constantes de Papyrus salen del código

Desde el módulo 1, NOMBRE_TIENDA, IVA_LIBROS y DESCUENTO_SOCIO viven escritas en el código. Funciona, pero cambiar el descuento de socio obliga a editar un .py. Lo profesional es externalizar la configuración a un archivo que un no-programador pueda tocar — y JSON con indent es ideal:

{
    "nombre_tienda": "Papyrus",
    "iva_libros": 0.04,
    "descuento_socio": 0.05
}
import json

def cargar_config(ruta="config.json"):
    with open(ruta, "r", encoding="utf-8") as f:
        return json.load(f)

config = cargar_config()
NOMBRE_TIENDA = config["nombre_tienda"]      # "Papyrus"
IVA_LIBROS = config["iva_libros"]            # 0.04 — float de verdad, listo para multiplicar
DESCUENTO_SOCIO = config["descuento_socio"]  # 0.05

Si mañana el gremio pacta otro descuento, Ana edita config.json con el Bloc de notas y ningún .py cambia. Observa que los tipos llegan correctos sin conversión: 0.04 es float porque JSON lo recuerda. (¿Y si config.json no existe o alguien lo rompe editándolo? json.load lanza JSONDecodeError — otro cliente esperando en la puerta del módulo 7.)

Una mención a pickle

La biblioteca estándar incluye otra vía de persistencia: pickle, que serializa objetos Python tal cual — sets, fechas, tus clases, casi cualquier cosa — a un formato binario (archivos en modo "wb"/"rb", los que 06-01 dejó apuntados):

import pickle

with open("catalogo.pkl", "wb") as f:      # binario: sin encoding
    pickle.dump(catalogo, f)               # el dict[str, Libro] entero, objetos incluidos

with open("catalogo.pkl", "rb") as f:
    recargado = pickle.load(f)             # vuelven objetos Libro, sin asdict ni **

Tentador, pero tiene tres letras pequeñas que lo relegan a usos puntuales: es solo-Python (ningún otro lenguaje ni Excel lo leerá), es ilegible (no puedes abrirlo en un editor para inspeccionarlo, la gran ventaja del texto que venimos explotando) y es inseguro con datos ajenos — despicklear un archivo de origen no confiable puede ejecutar código arbitrario; la propia documentación oficial lo advierte. Criterio del curso: para guardar estado propio y temporal entre ejecuciones tuyas, vale; para intercambiar, configurar o archivar, texto (CSV/JSON) siempre. Papyrus se queda con JSON.

Errores Comunes y Consejos

  • Confundir las cuatro funciones: json.dump(datos, "catalogo.json") falla — dump quiere un archivo abierto, no una ruta. Y json.load(texto) también — para cadenas es loads. Nemotecnia: la s es de string.
  • Intentar serializar objetos directamente: json.dump(catalogo, f) con valores Libro lanza TypeError: ... not JSON serializable. Primero asdict() (o construye dicts/listas a mano).
  • Olvidar que las tuplas y los sets no sobreviven: la tupla vuelve como lista y el set ni siquiera se escribe (TypeError). Convierte el set a list antes y reconviértelo al cargar si te importa la deduplicación.
  • Claves no textuales: {2026: "año"} se guarda como {"2026": "año"} y al volver la clave es str. Si la clave es un número que importa, guárdala como valor.
  • Editar JSON a mano y romperlo: una coma final tras el último elemento ("stock": 4,}) o comillas simples son JSON inválido → JSONDecodeError al cargar. Los editores modernos lo marcan; hasta el módulo 7, revisa antes de guardar.
  • Escribir sin ensure_ascii=False en archivos que leerán personas: las tildes se vuelven í y el archivo parece corrupto sin estarlo.
  • Consejo: para inspeccionar un JSON compacto recibido de fuera, print(json.dumps(datos, indent=4, ensure_ascii=False)) lo "embellece" al instante. Es el pretty-print de depuración estándar.

Ejercicios

Ejercicio 1: el estado completo de la tienda

Escribe guardar_estado(catalogo, ruta="estado.json") que guarde en un único JSON un objeto con tres claves: "tienda" (el nombre, desde la constante), "guardado" (la fecha de hoy como cadena str(date.today()) — recuerda que date no se serializa) y "catalogo" (la lista de asdict()). Escribe también cargar_estado(ruta) que devuelva la tupla (fecha, dict[str, Libro]). Comprueba el round-trip con los cuatro libros canónicos.

Ejercicio 2: la compra queda en el historial

Escribe registrar_compra_socio(codigo, compra, ruta="socios.json") que cargue el JSON de socios, localice el socio por su campo "codigo", añada el diccionario compra a su lista "compras" y reescriba el archivo completo (patrón cargar → modificar → guardar). Registra a Pau (PAU-003) la compra {"fecha": "2026-07-13", "titulo": "Fausto", "importe": 20.75} — por fin llegaron ejemplares — y comprueba releyendo el archivo que su lista ya no está vacía. Si el código no existe, imprime un aviso y no toques el archivo.

Ejercicio 3: criterio de formato

Para cada dato de Papyrus, decide CSV o JSON y justifícalo en un comentario de una línea: (a) el histórico de diez años de ventas que Ana filtra en Excel cada trimestre; (b) las preferencias de la aplicación de caja (nombre, IVA, descuento, ¿imprimir ticket?); (c) los socios con sus listas de compras; (d) la lista de novedades que la distribuidora importa en su sistema, "en lo que sea, pero que lo abra todo el mundo". Después escribe la ficha del socio Luis (con dos compras) en ambos formatos y observa cuál fuerza la estructura.

Soluciones

Solución 1:

import json
from datetime import date
from dataclasses import asdict
from pathlib import Path

def guardar_estado(catalogo, ruta="estado.json"):
    estado = {
        "tienda": NOMBRE_TIENDA,
        "guardado": str(date.today()),            # date → str: el peaje de lo no serializable
        "catalogo": [asdict(l) for l in catalogo.values()],
    }
    with open(ruta, "w", encoding="utf-8") as f:
        json.dump(estado, f, indent=4, ensure_ascii=False)

def cargar_estado(ruta="estado.json"):
    if not Path(ruta).exists():
        return None, {}
    with open(ruta, "r", encoding="utf-8") as f:
        estado = json.load(f)
    catalogo = {normalizar_titulo(d["titulo"]): Libro(**d) for d in estado["catalogo"]}
    return estado["guardado"], catalogo

guardar_estado(catalogo)
fecha, recargado = cargar_estado()
print(fecha, recargado == catalogo)      # 2026-07-13 True

Un solo archivo con metadatos + datos: el embrión del "guardar partida" de cualquier aplicación.

Solución 2:

import json

def registrar_compra_socio(codigo, compra, ruta="socios.json"):
    with open(ruta, "r", encoding="utf-8") as f:
        socios = json.load(f)

    for socio in socios:
        if socio["codigo"] == codigo:
            socio["compras"].append(compra)       # modificar en memoria...
            break
    else:                                         # el for/else de 02-02: no se encontró
        print(f"AVISO: no existe el socio {codigo}")
        return

    with open(ruta, "w", encoding="utf-8") as f:  # ...y reescribir entero
        json.dump(socios, f, indent=4, ensure_ascii=False)

registrar_compra_socio("PAU-003", {"fecha": "2026-07-13", "titulo": "Fausto", "importe": 20.75})

JSON no se puede "apendear" como el CSV de ventas: el archivo es una estructura completa, así que el patrón es siempre cargar → modificar → volcar. (20.75 es la cifra canónica: el precio de socio de Fausto.)

Solución 3:

# (a) CSV   — tabular, masivo y destino Excel: su hábitat exacto
# (b) JSON  — tipos mezclados (str, float, bool) y estructura de configuración
# (c) JSON  — listas anidadas dentro de cada socio; CSV lo forzaría
# (d) CSV   — "que lo abra todo el mundo" es la definición de lingua franca

Al escribir la ficha de Luis en ambos: en JSON las compras anidan con naturalidad; en CSV acabas repitiendo Luis,LUIS-001 en cada fila de compra o inventando un subformato dentro de una celda — la señal inequívoca de que el formato no encaja.

Conclusión

JSON completa el arsenal de persistencia de Papyrus: dump/load para archivos y dumps/loads para cadenas, tipos que sobreviven al viaje (adiós al peaje strfloat), indent=4, ensure_ascii=False para archivos legibles por humanos, asdict() + Libro(**datos) como puente idiomático entre dataclasses y disco, anidamiento para los socios con sus compras y un config.json que saca las constantes del código. CSV para tablas con destino Excel, JSON para estructura y configuración, pickle solo para estado propio y con cautela. Pero fíjate en lo que se nos está acumulando: registro_altas.txt, catalogo.csv, ventas.csv, catalogo.json, socios.json, config.json… media docena de archivos sueltos en la misma carpeta que el código. Una tienda ordenada no amontona las cajas junto a la caja registradora: hacen falta carpetas (datos/, copias/, informes/), rutas que funcionen en cualquier máquina y copias de seguridad nocturnas con fecha. De organizar todo eso — con pathlib como protagonista y el módulo os que el módulo 3 dejó prometido — se ocupa la última lección del módulo.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados