17 de marzo de 2026
La serialización JSON que consumía el 65% del CPU en un servicio Python
Un servicio FastAPI con 18 instancias gastaba $12,420/mes. El 65% del CPU se iba en serializar JSON. Un módulo Rust de 300 líneas lo bajó a 6 instancias.
Un servicio FastAPI sirve un catálogo de productos. A 600 RPS, el equipo escala a 18 instancias c5.xlarge para mantener la latencia bajo control. El billing de AWS marca $12,420/mes solo en compute. La latencia p99 ronda los 890ms — demasiado cerca del timeout de los clientes que consumen la API.
El equipo sospecha que necesitan instancias más grandes o una reescritura parcial. Pero antes de escalar, alguien corre py-spy en producción durante 60 segundos. El flame graph muestra que el 65% del tiempo de CPU se concentra en dos funciones: la serialización de modelos Pydantic y json.dumps. No en queries a la base de datos. No en lógica de negocio. En convertir objetos Python a texto JSON.
Por qué la serialización consume tanto CPU
Cada respuesta del servicio es una lista de 50 a 200 productos con objetos anidados: variantes, niveles de precio, inventario por ubicación. El payload promedio es 85KB.
La serialización de un objeto Python a JSON requiere recorrer recursivamente toda la estructura de datos, convertir cada valor a su representación en string, y concatenar el resultado. En CPython, cada paso involucra overhead del intérprete: lookup de tipos, dispatch de métodos, allocación de strings intermedios.
Con Pydantic V2 — que ya usa un core en Rust para validación — la serialización del modelo a dict sigue pasando por Python para la conversión final a JSON. El método .model_dump() produce un diccionario Python nativo que después se serializa con json.dumps del módulo estándar. Cada llamada a json.dumps sobre un dict de 85KB toma ~8ms de CPU puro.
Pero el problema real no es solo la velocidad de json.dumps. Es el GIL.
El GIL como multiplicador del costo
Python tiene un Global Interpreter Lock que impide que múltiples threads ejecuten bytecode simultáneamente. En un servicio async con uvicorn y FastAPI, el event loop procesa requests de forma concurrente pero no paralela: cuando una coroutine hace I/O (query a la DB, llamada HTTP), cede el control y otra coroutine avanza. Pero cuando una coroutine ejecuta código CPU-bound como serialización JSON, el event loop se bloquea hasta que termina.
Con 600 RPS distribuidos en 18 instancias, cada instancia maneja ~33 RPS. A 8ms de serialización por response:
33 requests/s × 8ms = 264ms de CPU por segundo en serialización
El event loop de cada instancia está bloqueado el 26% del tiempo solo en serialización. Mientras serializa una respuesta, todas las demás requests en cola esperan. Esto produce un efecto cascada: una request que llega durante la serialización de otra acumula latencia de espera, lo que explica el p99 de 890ms a pesar de que el p50 es 45ms.
Lo que el equipo probó antes
| Intento | Resultado | Por qué no fue suficiente |
|---|---|---|
Reemplazar json.dumps por ujson | 15% más rápido (~6.8ms por response) | Mejora real pero no cambia la magnitud. El event loop sigue bloqueado 22% del tiempo |
| Paginar respuestas (máximo 20 productos) | Latencia por request mejoró | Pero los clientes hacen más requests para obtener el catálogo completo. El throughput total sigue limitado por serialización |
| Cache de respuestas completas en Redis | Funciona para endpoints estáticos | El catálogo cambia cada pocos minutos. La tasa de cache miss es demasiado alta para los endpoints más consultados |
Cada intento mejora un aspecto pero no ataca la raíz: la serialización de payloads grandes en Python es inherentemente costosa porque cada operación pasa por el intérprete y el GIL.
El diagnóstico con py-spy
El profiling con py-spy es no invasivo — se conecta al proceso sin reiniciarlo ni modificar el código:
py-spy record -d 30 --pid <pid> -o profile.svg
El flame graph de 30 segundos muestra la distribución del tiempo de CPU:
| Función | % del CPU |
|---|---|
pydantic.BaseModel.model_dump + json.dumps | 65% |
| SQLAlchemy query execution | 18% |
| FastAPI routing + middleware | 9% |
| Lógica de negocio (filtros, cálculos) | 8% |
El 65% concentrado en serialización confirma que el bottleneck no es la base de datos ni la lógica de negocio. Es la conversión de objetos Python a bytes JSON.
La intervención: un módulo nativo en Rust vía PyO3
Si el 65% del CPU se va en serializar Python dicts a JSON, y esa operación es pura (recibe datos, devuelve bytes, sin side effects), se puede mover fuera del intérprete de Python a código nativo que no pase por el GIL.
PyO3 permite escribir módulos nativos de Python en Rust. El módulo recibe los objetos Python (la lista de productos ya como dicts), serializa a JSON usando serde_json, y devuelve bytes directamente al response ASGI. Mientras el código Rust ejecuta, libera el GIL — lo que permite que el event loop siga procesando otras requests.
Qué hace el módulo
Input: list[dict] (productos como dicts Python, post model_dump)
Output: bytes (JSON serializado, listo para el response body)
El flujo del request queda:
- FastAPI recibe el request, ejecuta la query a la DB → no cambia
- Pydantic valida y estructura los datos → no cambia
- Módulo Rust recibe los dicts, serializa a JSON, devuelve bytes → reemplaza
json.dumps - ASGI envía los bytes al cliente → no cambia
La lógica de negocio, validación, queries, y manejo de errores siguen en Python. Solo el hot path de serialización se mueve a Rust.
El módulo es ~300 líneas de Rust. Se construye con maturin, que genera un wheel compatible con el Python de producción:
maturin build --release --interpreter python3.11
pip install target/wheels/catalog_serializer-*.whl
Por qué Rust y no otra opción
| Alternativa | Problema |
|---|---|
| C con Python C API | Funciona, pero sin safety de memoria. Un buffer overflow en un serializer que procesa input variable en producción es un riesgo de seguridad real |
| Go como microservicio separado | Agrega latencia de red (~1-3ms por hop), complejidad operativa (otro deploy, otro servicio que monitorear), y no resuelve el problema — lo mueve |
| Cython | Acelera ~2-3x pero sigue ejecutando en el intérprete para operaciones sobre dicts. No puede competir con serde_json para serialización pura |
orjson (biblioteca existente en Rust) | Alternativa viable — es un drop-in para json.dumps escrito en Rust. Serializa ~4x más rápido que json.dumps. Pero para payloads de 85KB con estructuras anidadas conocidas, un serializer tipado con serde_json es aún más rápido porque evita el dispatch dinámico de tipos |
| Rust vía PyO3 | Sin GC, memory safety garantizada por el compilador, serde_json es uno de los serializers JSON más rápidos disponibles, y libera el GIL durante la ejecución |
La decisión no es "Rust es mejor que Python" — es que este componente específico ejecuta trabajo CPU-bound puro que bloquea el event loop, y moverlo a código nativo que libera el GIL elimina el bloqueo sin cambiar la arquitectura del servicio.
Por qué no usar orjson directamente
orjson es una alternativa seria. Está escrito en Rust, es un drop-in para json.dumps, y para muchos casos es suficiente. Si el payload promedio fuera <20KB o el throughput fuera <200 RPS, orjson sería la respuesta correcta con cero esfuerzo de desarrollo.
En este caso, el módulo custom gana por dos razones:
- Serialización tipada: serde_json con structs definidos evita el overhead de inspeccionar el tipo de cada valor en runtime. Para un dict Python con 200+ nested objects, el dispatch dinámico de
orjsonsuma ~30% más de tiempo que un serializer que ya conoce la estructura - Conversión directa: el módulo recibe la lista de dicts y convierte a structs Rust internamente.
orjsonnecesita recorrer la estructura Python dict → JSON. El módulo custom hace Python dict → Rust struct → JSON, lo que permite optimizaciones de layout en memoria
Para un servicio con payloads más simples o menor throughput, orjson es la recomendación.
El resultado
| Métrica | Antes | Después |
|---|---|---|
| Serialización por response (85KB) | ~8ms (Python json/Pydantic) | ~0.9ms (Rust serde_json vía PyO3) |
| CPU en serialización | 65% | 12% |
| Instancias necesarias a 600 RPS | 18 | 6 |
| Costo mensual infra (c5.xlarge) | $12,420/mes | $4,140/mes |
| Latencia p50 | 45ms | 18ms |
| Latencia p99 | 890ms | 95ms |
La mejora en p99 (de 890ms a 95ms) es desproporcionada respecto a la mejora en tiempo de serialización (8ms → 0.9ms) porque el efecto principal no es serializar más rápido — es desbloquear el event loop. Con el GIL liberado durante la serialización, las requests concurrentes no se apilan esperando.
La reducción de 18 a 6 instancias representa $8,280/mes de ahorro — $99,360/año en una sola línea del billing.
Cuándo este patrón aplica
La extracción del hot path de serialización a código nativo tiene sentido cuando:
- CPU profiling muestra >40% del tiempo en serialización/deserialización. Medido con py-spy, cProfile, o cualquier profiler que muestre distribución de CPU. Si la serialización es <20% del CPU, hay otro bottleneck más impactante
- Los payloads promedio superan 20KB. Para payloads chicos (<5KB),
json.dumpstoma <1ms y el bloqueo del event loop es negligible - El throughput supera 200 RPS. A bajo throughput, el event loop tiene suficiente idle time para absorber la serialización sin efecto cascada
- La estructura del payload es estable. El módulo Rust define structs que mapean la estructura de los datos. Si el schema cambia cada sprint, el costo de mantener el módulo en sync supera el beneficio
Cuándo no
- Si el bottleneck es I/O (queries lentas, llamadas a servicios externos, timeouts de red). Optimizar la serialización cuando el 80% del tiempo está en la base de datos no cambia nada. El flame graph lo muestra en 30 segundos
- Si el caching resuelve el problema. Un catálogo que cambia una vez por hora se puede cachear como bytes JSON en Redis. Costo de desarrollo: una tarde. Sin Rust, sin módulos nativos
- Si el equipo no puede mantener un build de Rust en CI. maturin simplifica el proceso significativamente, pero sigue siendo una dependencia adicional. Si no hay nadie que pueda debuggear un fallo de compilación de Rust en el pipeline, la deuda operativa crece
- Si
orjsones suficiente. Para muchos servicios Python, reemplazarjson.dumpspororjson.dumpsda una mejora de 3-5x con cero líneas de Rust. Probar esto primero toma 10 minutos - Si migrar a un lenguaje compilado es viable. Si el servicio completo puede reescribirse en Go o Rust y el equipo tiene la capacidad, eso elimina el problema de raíz en vez de parchearlo. Pero una reescritura de 3-6 meses tiene un costo y un riesgo que 300 líneas de Rust no tienen
Si tu servicio Python gasta más CPU en serializar que en resolver lógica de negocio y quieres saber si el hot path es candidato a extracción nativa, podemos revisarlo en una conversación de 15 minutos sin costo.
Referencias
- PyO3 — Rust bindings for Python — framework para escribir módulos nativos de Python en Rust, incluyendo liberación del GIL durante ejecución nativa
- serde_json — docs.rs — biblioteca de serialización JSON de Rust usada en el módulo nativo
- py-spy — GitHub — profiler de sampling para Python que se conecta a procesos en producción sin reiniciarlos
- Python json module — Documentación oficial — módulo estándar de serialización JSON cuyo overhead es el origen del bottleneck
- Python GIL — Glosario oficial — definición del Global Interpreter Lock y su impacto en concurrencia CPU-bound
- Pydantic V2 Performance — documentación de performance de Pydantic V2, incluyendo el core en Rust para validación
- maturin — Build tool for PyO3 — herramienta de build que genera wheels Python a partir de código Rust
- uvicorn — ASGI server — servidor ASGI usado con FastAPI, relevante para el modelo de event loop y concurrencia
- The Rust Programming Language: Understanding Ownership — modelo de ownership de Rust que garantiza memory safety sin garbage collector
- orjson — GitHub — biblioteca de serialización JSON para Python escrita en Rust, alternativa evaluada en el análisis