JSON en Python: stdlib, encoders custom y alternativas rápidas

El módulo json de la stdlib es tu puerta de entrada a la serialización, pero tiene límites de velocidad y de tipos que en producción se notan. Vamos a ver exactamente dónde está cada límite y cómo cruzarlo.

json.dumps() convierte un objeto Python en una cadena JSON, y json.loads() hace el camino inverso. Internamente, el módulo traversa el objeto con un JSONEncoder que reconoce un conjunto fijo y pequeño de tipos: dict, list, tuple, str, int, float, bool y None. Cualquier otra cosa lanza TypeError en tiempo de ejecución, no en tiempo de importación, lo que significa que el error aparece cuando ya estás sirviendo tráfico.

El diseño es intencionalmente minimalista: JSON el formato no tiene concepto de datetime, Decimal ni bytes, así que la stdlib no asume ninguna convención de serialización para esos tipos. Esa decisión te obliga a ser explícito, lo cual es correcto. El problema es que “ser explícito” con la stdlib requiere subclasificar json.JSONEncoder y sobreescribir su método default(), que solo se llama para tipos desconocidos.

Cuando esto importa en producción: si tienes una API que serializa miles de objetos por segundo, el encoder puro de Python se convierte en cuello de botella medible. orjson (Rust) y ujson (C) resuelven eso con implementaciones nativas, y además orjson añade soporte nativo de datetime, dataclasses y arrays de NumPy sin encoder custom.

Lo que se rompe cuando lo haces mal: pasar un tipo no soportado sin encoder custom, usar default=str como parche general (serializa silenciosamente cosas que no deberían serializarse como string), o asumir que orjson.dumps() devuelve str cuando en realidad devuelve bytes.

import json
import datetime
from decimal import Decimal
from dataclasses import dataclass, field
from typing import Any

import orjson  # pip install orjson


# ── 1. Encoder custom para la stdlib ─────────────────────────────────────────

class AppEncoder(json.JSONEncoder):
    """Extiende el encoder base para tipos que aparecen en APIs reales."""

    def default(self, obj: Any) -> Any:
        # default() solo se llama cuando el encoder base no reconoce el tipo.
        if isinstance(obj, datetime.datetime):
            return obj.isoformat()
        if isinstance(obj, Decimal):
            # Convertir a str preserva la precisión; float puede perderla.
            return str(obj)
        if isinstance(obj, bytes):
            return obj.hex()
        # Dejar que la clase base lance TypeError para tipos realmente inesperados.
        return super().default(obj)


# ── 2. Tipos de datos de ejemplo ──────────────────────────────────────────────

@dataclass
class Order:
    order_id: str
    amount: Decimal
    created_at: datetime.datetime
    tags: list[str] = field(default_factory=list)
    metadata: bytes = b""


order = Order(
    order_id="ORD-9981",
    amount=Decimal("149.99"),
    created_at=datetime.datetime(2024, 6, 15, 10, 30, 0, tzinfo=datetime.timezone.utc),
    tags=["express", "fragile"],
    metadata=b"\xde\xad\xbe\xef",
)


# ── 3. stdlib: requiere encoder + conversión manual del dataclass ─────────────

order_dict = {
    "order_id": order.order_id,
    "amount": order.amount,
    "created_at": order.created_at,
    "tags": order.tags,
    "metadata": order.metadata,
}

stdlib_json = json.dumps(order_dict, cls=AppEncoder, indent=2)
print("── stdlib ──")
print(stdlib_json)

# Deserializar: los tipos siguen siendo str/str, no Decimal/datetime.
# La stdlib no sabe cómo revertir la conversión; eso es responsabilidad tuya.
parsed = json.loads(stdlib_json)
assert isinstance(parsed["amount"], str)       # "149.99", no Decimal
assert isinstance(parsed["created_at"], str)   # ISO 8601, no datetime


# ── 4. orjson: soporte nativo de dataclasses y datetime ──────────────────────

# orjson.dumps() devuelve bytes, no str. Diseño intencional: evita una
# codificación UTF-8 redundante cuando envías la respuesta por red.
orjson_bytes: bytes = orjson.dumps(order, option=orjson.OPT_INDENT_2)
print("\n── orjson ──")
print(orjson_bytes.decode())

# Si necesitas str (p.ej. para insertar en un template):
orjson_str: str = orjson_bytes.decode("utf-8")

# Deserializar con orjson es un drop-in: devuelve dict/list/str/int/float/bool/None.
parsed_orjson = orjson.loads(orjson_bytes)
assert parsed_orjson["amount"] == "149.99"     # Decimal serializado como str


# ── 5. Benchmark mínimo para tener intuición de la diferencia ────────────────

import timeit

N = 10_000
data = {"key": "value", "ts": datetime.datetime.now(), "items": list(range(50))}

# La stdlib necesita el encoder; orjson lo maneja nativamente.
stdlib_time = timeit.timeit(
    lambda: json.dumps(
        {**data, "ts": data["ts"].isoformat()},  # pre-convertir datetime
    ),
    number=N,
)
orjson_time = timeit.timeit(lambda: orjson.dumps(data), number=N)

print(f"\nstdlib : {stdlib_time:.3f}s para {N} iteraciones")
print(f"orjson : {orjson_time:.3f}s para {N} iteraciones")
print(f"speedup: {stdlib_time / orjson_time:.1f}×")

Qué está pasando en cada capa

El AppEncoder.default() es la pieza clave de la stdlib. Solo entra en juego cuando el traversal interno encuentra un tipo que no está en la lista nativa, y tu responsabilidad es devolver algo que sea serializable, no una string JSON. Si devuelves obj.isoformat(), el encoder sigue procesando el string resultante con normalidad. Si devuelves otro objeto no soportado, default() se llama de nuevo recursivamente hasta que lances TypeError o devuelvas algo válido.

El super().default(obj) al final no es decorativo: garantiza que el TypeError tenga el mensaje correcto y que subclases encadenadas funcionen. Omitirlo y poner raise TypeError(...) manualmente es una trampa sutil.

La conversión de Decimal a str en lugar de float es deliberada. float(Decimal("149.99")) puede producir 149.99000000000001 dependiendo del valor, corrompiendo silenciosamente datos financieros.

Con orjson, el dataclass se serializa directamente porque la librería usa dataclasses.fields() internamente. No necesitas asdict() ni conversión manual. El option=orjson.OPT_INDENT_2 es el equivalente a indent=2 de la stdlib, pero la mayoría de APIs de producción no lo usan porque el whitespace extra aumenta el tamaño del payload sin beneficio.

El retorno de bytes en orjson.dumps() no es un descuido de API: cuando construyes una respuesta HTTP, el framework ya necesita bytes. Insertar una codificación UTF-8 intermedia solo para tener str y luego re-encodar es trabajo desperdiciado.

Errores que debes conocer

Error: usar default=str como encoder rápido para “que no falle”, lo que serializa silenciosamente objetos que no deberían serializarse como strings.

# ❌ Wrong
data = {"user": user_object, "amount": Decimal("9.99")}
json.dumps(data, default=str)
# Produce: {"user": "<User id=42>", "amount": "9.99"}
# El repr del objeto entra en producción sin que nadie se dé cuenta.

# ✅ Right
class StrictEncoder(json.JSONEncoder):
    def default(self, obj):
        if isinstance(obj, Decimal):
            return str(obj)
        return super().default(obj)  # TypeError explícito para tipos inesperados

json.dumps(data, cls=StrictEncoder)

Que el encoder base lance TypeError es una feature: te fuerza a decidir explícitamente cómo serializar cada tipo no estándar.


Error: asumir que orjson.dumps() devuelve str y concatenarlo directamente con otras strings.

# ❌ Wrong
response_body = '{"wrapper":' + orjson.dumps(data) + "}"
# TypeError: can only concatenate str (not "bytes") to str

# ✅ Right
response_body = b'{"wrapper":' + orjson.dumps(data) + b"}"
# O si necesitas str:
response_body = '{"wrapper":' + orjson.dumps(data).decode() + "}"

orjson.dumps() siempre devuelve bytes; tratarlo como str falla en tiempo de ejecución con un error de tipo que puede sorprenderte si vienes de la stdlib.


Error: esperar que orjson.loads() reconstruya datetime o Decimal automáticamente desde el JSON deserializado.

# ❌ Wrong — suposición incorrecta
data = orjson.loads('{"ts": "2024-06-15T10:30:00+00:00"}')
assert isinstance(data["ts"], datetime.datetime)  # AssertionError: es str

# ✅ Right — la deserialización de tipos custom siempre es manual
raw = orjson.loads('{"ts": "2024-06-15T10:30:00+00:00"}')
data = {**raw, "ts": datetime.datetime.fromisoformat(raw["ts"])}

JSON no tiene tipo fecha; la reconstrucción es siempre responsabilidad del consumidor, independientemente de la librería que uses.

184

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio