Módulo 5 · Lección 3 de 30
JSON
Los cuatro tipos de Python que se transforman en otra cosa sin avisar, el precio que sale a 199,89999999999998, y las fechas que JSON no sabe guardar.
JSON es el formato en el que viaja casi todo lo que hace una API, y tiene fama de trivial: cuatro tipos, dos funciones y a correr. Esa fama es merecida en el 90 % de los casos y es exactamente lo que hace peligroso el 10 % restante, porque los fallos de JSON no dan error: los datos llegan, se convierten en algo parecido pero distinto, y el programa sigue.
Esta lección es ese 10 %.
El mapeo#
JSON tiene seis tipos y Python tiene muchos más, así que la correspondencia es incompleta por definición:
| JSON | Python al leer | Python al escribir |
|---|---|---|
object | dict | dict |
array | list | list, tuple |
string | str | str |
number | int o float | int, float |
true / false | bool | bool |
null | None | None |
Dos funciones, y conviene tener clara la diferencia porque los nombres son confusos:
json.dumps(objeto) # a texto (la s es de string)
json.loads(texto) # desde texto
json.dump(obj, f) # a un fichero
json.load(f) # desde un ficheroLo que no sobrevive al viaje#
Aquí está lo que importa. Una ida y vuelta por JSON con varios tipos de Python:
tupla tuple -> list [1, 2, 3]
clave entera dict -> dict {'1': 'uno'}
conjunto set -> TypeError: Object of type set is not JSON serializable
decimal Decimal -> TypeError: Object of type Decimal is not JSON serializable
fecha date -> TypeError: Object of type date is not JSON serializable
bytes bytes -> TypeError: Object of type bytes is not JSON serializableLos cuatro últimos fallan ruidosamente, y eso está bien: te enteras.
Los dos primeros son los peligrosos, porque funcionan y cambian tus datos:
Una tupla vuelve como lista. Suele dar igual, hasta que algo compara (1, 2) == [1, 2], que es falso, o intenta usarla como clave de un diccionario.
Una clave entera vuelve como texto. Esta es la que muerde de verdad:
>>> json.loads(json.dumps({1: 'uno'}))
{'1': 'uno'}Guardas {42: 'Ana'}, lo lees, y datos[42] da KeyError mientras datos['42'] funciona. El motivo es que en JSON las claves solo pueden ser texto, así que Python convierte, y no avisa porque no lo considera un error.
Si tu diccionario tiene claves numéricas y va a viajar por JSON, hay dos opciones: aceptarlo y usar cadenas en los dos lados desde el principio, o mandar una lista de objetos, que es lo que hacen las APIs bien diseñadas.
El dinero#
Este es el problema práctico número uno de JSON y no es culpa de JSON: es que los decimales binarios no pueden representar exactamente 0,1, y lo arrastra cualquier programa que use float.
sumar diez veces 19.99 -> 199.89999999999998 (debería ser 199.90)
8.35 * 3 -> 25.049999999999997 (debería ser 25.05)
round(8.35, 1) -> 8.3 (debería ser 8.4)El tercero es el que más desconcierta: redondear a un decimal da 8,3 y no 8,4, porque el 8.35 guardado es en realidad un pelín menor que 8,35.
En una factura eso son céntimos que no cuadran, y no hay manera de arreglarlo a posteriori. La solución es no usar float para dinero nunca, y el módulo json trae el gancho:
import decimal
datos = json.loads(texto, parse_float=decimal.Decimal)parse_float=Decimal: 8.35 * 3 -> 25.05Exacto, porque Decimal trabaja en base diez como las personas.
Y para el otro lado, escribir, hay que decirle cómo, porque Decimal no es serializable:
json.dumps(datos, default=str)Las fechas#
JSON no tiene tipo fecha. Punto. Lo que hay es el acuerdo de escribirlas como texto en formato ISO 8601, que es lo que devuelve GitHub:
"created_at": "2012-04-28T02:47:18Z"Y leerlas es una línea de la biblioteca estándar:
fromisoformat('2012-04-28T02:47:18Z') -> 2012-04-28 02:47:18+00:00 tz=UTC
fromisoformat('2026-09-22T12:00:00+02:00') -> 2026-09-22 12:00:00+02:00 tz=UTC+02:00
fromisoformat('2026-09-22') -> 2026-09-22 00:00:00 tz=None
fromisoformat('2026-09-22T12:00:00.123456Z') -> ... .123456+00:00 tz=UTCLos cuatro formatos, incluido el de la Z al final, que Python solo acepta desde la versión 3.11. Si ves por ahí código con s.replace('Z', '+00:00'), es de antes de eso.
Fíjate en el tercero: una fecha sin hora sale sin zona horaria, y mezclar fechas con y sin zona lanza un TypeError al compararlas. Es un fallo que aparece semanas después.
Para escribir, hay un atajo que casi funciona y conviene no usar:
json.dumps(fecha) -> TypeError: Object of type datetime is not JSON serializable
json.dumps(fecha, default=str) -> {"cuando": "2026-09-22 12:00:00+00:00"}
fecha.isoformat() -> {"cuando": "2026-09-22T12:00:00+00:00"}Mira la diferencia entre las dos últimas: un espacio en lugar de la T. El default=str llama a str(), que en datetime produce un formato parecido a ISO pero que no lo es. Muchos analizadores lo aceptan, otros no, y el fallo aparece solo con el cliente escrito en otro lenguaje.
La forma correcta es explícita:
json.dumps(datos, default=lambda o: o.isoformat() if hasattr(o, 'isoformat') else str(o))El texto y el español#
json.dumps({'nombre': 'Íñigo Ñuño', 'emoji': '🌐'}){"nombre": "\u00cd\u00f1igo \u00d1u\u00f1o", "emoji": "\ud83c\udf10"}Legible no es. Eso pasa porque ensure_ascii vale True por defecto, que es una herencia de cuando no se podía dar por hecho que todo fuera UTF-8.
json.dumps(datos, ensure_ascii=False){"nombre": "Íñigo Ñuño", "emoji": "🌐"}bytes: 69 con escapes, 45 sin ellos
los dos se leen igual: TrueLos dos son JSON válido y los dos se leen igual, así que esto no es una cuestión de corrección: es de legibilidad y de tamaño. Un tercio menos de bytes en texto español, y registros que se pueden leer.
La recomendación para un proyecto en español: ensure_ascii=False siempre, asegurándose de que el fichero o la respuesta van en UTF-8, cosa que en una API es automática.