Finding the Last Bottlenecks in json2xml with Flamegraphs | Vinit KumarI released json2xml-rs 0.4.2 and json2xml 6.5.0 after a focused profiling session across both implementations.
The final result:
Pure Python conversion improved from 83.0 ms to 57.2 ms , a 31.1% reduction .
The Rust accelerator improved from 6.007 ms to 5.632 ms , a further 6.23% reduction .
Both implementations produced identical XML before and after the changes.
The Python suite reached 100% statement coverage: 421 tests covering all 762 statements .
The Rust release shipped first, followed by the Python wrapper that requires it.
This post documents not only the code that survived, but also the prompts, rejected experiments, review feedback, release failures, and verification work that got it over the line.
How the session started
My first prompt was intentionally outcome-focused:
I want to push a bit more optimisation. Let’s use flamegraph to find the bottleneck and implement fix. Do it from a new branch and use latest python 3.15 beta to do so. Use uv to manage python.
That established four useful constraints before touching code:
Profile first.
Work on an isolated branch.
Use the latest CPython 3.15 beta rather than an older local default.
Let uv manage the interpreter and environments so the benchmark setup is reproducible.
All Python measurements in this post used uv-managed CPython 3.15.0b3 and the same deterministic 5,000-record nested payload.
I then asked for the evidence to be part of the change:
Make a PR after committing code for this and add the before -> after SVG showing the improvements.
That mattered. A benchmark number in a PR description is useful, but a committed flamegraph preserves the call tree that led to the change. Future maintainers can inspect what actually moved instead of trusting a single summary.
The Python bottleneck: repeated abstract type checks
The Python flamegraph showed that recursive conversion spent a surprising amount of time rediscovering object types. Common JSON-native values repeatedly passed through broad isinstance checks and helper calls while the serializer walked thousands of dictionary and list entries.
The retained optimization is deliberately straightforward. The serializer captures type(obj) once and sends exact built-ins through specialized branches:
bool
str
int, float, and complex
dict
list and tuple
Subclasses and less common numeric types still reach the broader compatibility fallbacks.
Here are the committed profiles:
Before<br>After
The measured effect was larger than I expected:
Pure Python metric<br>Before<br>After<br>Change
Conversion time<br>83.0 ms<br>57.2 ms<br>31.1% lower
20-loop traced time<br>8.311 s<br>5.782 s<br>30.4% lower
Function calls<br>48.17 million<br>30.13 million<br>37.4% fewer
isinstance calls<br>11.70 million<br>2.80 million<br>76.1% fewer
The win did not come from a clever algorithm. It came from avoiding millions of repeated general-purpose decisions on values whose types were already known.
The important review comment: what about numeric subclasses?
The optimization made dispatch faster, but it also made the order of exact-type and subclass checks more visible. I received this suggestion during review:
Numeric handling in _append_convert is split between exact-type checks and _is_number, which complicates the control flow and subclass behavior.
The suggested simplification was to route all numerics through _is_number, or at least explain why exact built-ins and subclasses take different routes.
This is where optimization work can easily become an accidental compatibility break. Python’s type relationships are wider than strict JSON:
bool is a subclass of int.
Decimal and Fraction implement numeric protocols without being exact built-in integers or floats.
Projects can pass custom Number implementations.
String, dictionary, list, and tuple subclasses may carry behavior that exact built-ins do not.
Routing every number through an abstract check would preserve generality but give back part of the measured win. Removing the fallback would be faster but incompatible.
The final design keeps the intentional split:
exact JSON-native built-in -> specialized hot path<br>subclass or broader number -> compatibility fallback
I added explicit tests for Decimal, Fraction, complex values, a custom Number, and subclasses of strings, dictionaries, lists, and tuples. Those tests also uncovered and fixed a pre-existing string-subclass conversion bug.
The review comment did not make the optimization broader. It made the contract sharper.
Then I asked for the same treatment in Rust
My next prompt was:
Do the same optimisation with the rust code using flamegraph. Use your elite level rust skills to squeeze the last performance boost and optimisation out of it.
The language was enthusiastic, but the method remained the same: profile the native path independently and do not assume the Python flamegraph explains Rust time.
The symbolized native profile showed the scalar...