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
- Qué es JSON y dónde vive
- Las cuatro funciones del módulo
json:dump,load,dumps,loads - Tipos Python ↔ JSON: la tabla de equivalencias
indentyensure_ascii=False: JSON legible y con tildes- El catálogo en JSON:
asdict()a la ida,Libro(**datos)a la vuelta - Anidamiento: los socios de Papyrus con sus compras
- JSON vs CSV: tabla de criterio
config.json: las constantes de Papyrus salen del código- Una mención a
pickle - Errores comunes y consejos
- 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.jsonde 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 incluidosEse 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 sonstr. 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,bytesy — atención — tus objetos:json.dumps(Libro("Hamlet", 9.95, 6))lanzaTypeError: Object of type Libro is not JSON serializable. La estrategia estándar es convertir a tipos básicos antes (unset→list, undate→"2026-07-13"constr()oisoformat(), 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 conencoding="utf-8"(06-01), podemos desactivarla y tener tildes de verdad en el archivo.
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 siempreCompara 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:
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.05Si 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 —dumpquiere un archivo abierto, no una ruta. Yjson.load(texto)también — para cadenas esloads. Nemotecnia: lases de string. - Intentar serializar objetos directamente:
json.dump(catalogo, f)con valoresLibrolanzaTypeError: ... not JSON serializable. Primeroasdict()(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 alistantes 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 esstr. 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 →JSONDecodeErroral cargar. Los editores modernos lo marcan; hasta el módulo 7, revisa antes de guardar. - Escribir sin
ensure_ascii=Falseen 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 TrueUn 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 francaAl 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 str → float), 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
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
