Benchmarks

Comprehensive performance comparison between all json2xml implementations.

Test Environment

  • Machine: Apple Silicon (arm64)

  • OS: macOS 26.5 (Darwin 25.5.0)

  • Python: 3.14.4

  • Date: May 27, 2026

  • CLI tools: json2xml-go and json2xml-zig from /Users/vinitkumar/.local/bin

Implementations Tested

Implementation

Type

Notes

Python

Library

Pure Python json2xml

Rust

Library

Native extension via PyO3, imported as json2xml_rs

Go

CLI

Standalone json2xml-go binary

Zig

CLI

Standalone json2xml-zig binary

Test Data

Size

Description

Bytes

Small

Simple object {"name": "John", "age": 30, "city": "New York"}

47

Medium

10 generated records with nested structures

3,215

bigexample.json

Real-world patent data

2,018

Large

100 generated records with nested structures

32,206

Very Large

1,000 generated records with nested structures

323,131

Results

Performance Summary

Test Case

Python

Rust

Go

Zig

Small (47B)

3.19µs

0.86µs

6.05ms

3.08ms

Medium (3.2KB)

214.83µs

18.41µs

5.85ms

3.12ms

bigexample (2KB)

91.20µs

7.32µs

5.76ms

3.08ms

Large (32KB)

2.07ms

175.46µs

5.89ms

3.73ms

Very Large (323KB)

21.20ms

1.48ms

6.82ms

7.82ms

Speedup vs Pure Python

Test Case

Rust

Go

Zig

Small (47B)

3.7x

0.0x*

0.0x*

Medium (3.2KB)

11.7x

0.0x*

0.1x*

bigexample (2KB)

12.5x

0.0x*

0.0x*

Large (32KB)

11.8x

0.4x*

0.6x*

Very Large (323KB)

14.4x

3.1x

2.7x

CLI tools have process spawn overhead of about 3-6ms, which dominates for small inputs.

Security Hardening Benchmark

This August 12, 2026 comparison measures the public Json2xml(...).to_xml() path before and after conversion limits, lexical pretty printing, and compact output by default were added. It is kept as a dated record; the pretty-output figures were superseded on August 23, 2026, when indentation moved into the serializer and the lexical formatter was removed.

It compares pre-hardening commit 826439f with hardened master at 48dfd38 on Apple Silicon and macOS 26.6.1. The primary run used CPython 3.14.6.

The deterministic benchmark used small, 100-record, and 1,000-record nested payloads generated by benchmarks/benchmark_security_hardening.py. Record fields depend only on their zero-based index, so no pseudorandom seed or hidden input is required.

The committed harness contains the exact payloads, loop counts, worker order, timing code, and UTF-8 output checks. It creates detached temporary worktrees for both revisions and launches fresh workers in mirrored ABBA and BAAB orders. Two passes provide four workers per revision; 17 samples per worker yield 68 samples per revision and cell after five warmups per worker.

Every child process receives an argv list with shell parsing disabled. User-supplied revision text follows Git’s --end-of-options marker, and only the resulting full commit ID is passed to git worktree add; no shell escaping is required.

Workload

Mode

Pre-hardening

Hardened master

Change

Small

Default

24.1µs

6.3µs

74.0% faster

Small

Compact

4.4µs

6.4µs

44.9% slower

Small

Pretty

24.3µs

15.1µs

38.0% faster

100 records

Default

7.07ms

1.80ms

74.6% faster

100 records

Compact

1.14ms

1.83ms

60.6% slower

100 records

Pretty

6.87ms

5.04ms

26.7% faster

1,000 records

Default

86.79ms

17.44ms

79.9% faster

1,000 records

Compact

10.92ms

17.40ms

59.3% slower

1,000 records

Pretty

85.03ms

49.26ms

42.1% faster

CPython 3.15.0rc1 Follow-up

uv 0.12.3 downloaded and managed the exact cpython-3.15.0rc1-macos-aarch64-none build. The follow-up uses the same payloads, modes, warmups, sample counts, worker isolation, and mirrored ABBA/BAAB ordering.

uv 0.11.24 did not yet list rc1, so it was updated to 0.12.3. That release downloaded the exact 26.2 MiB Apple Silicon build and verified its version before measurement.

uv self update
uv python list 3.15 --all-versions
uv run --isolated --managed-python \
  --python cpython-3.15.0rc1-macos-aarch64-none \
  --with defusedxml \
  --with 'urllib3>=2.7.0' \
  python benchmarks/benchmark_security_hardening.py \
  --before 826439f \
  --after 48dfd38 \
  --output-json /tmp/json2xml-security-benchmark-python315rc1.json

The console report includes medians, interquartile ranges, output types, UTF-8 byte counts, and SHA-256 values. The optional JSON file preserves all 1,224 raw timings: 9 cells × 2 revisions × 68 samples.

Each result is median [p25, p75]. The narrow hardened ranges contrast with the wider old DOM-pretty ranges on larger payloads.

Workload

Mode

Pre-hardening median [p25, p75]

Hardened median [p25, p75]

Change

Small

Default

24.7µs [24.6, 25.0]

6.2µs [6.2, 6.3]

74.8% faster

Small

Compact

4.4µs [4.4, 4.4]

6.4µs [6.3, 6.4]

44.0% slower

Small

Pretty

24.7µs [24.5, 24.8]

14.8µs [14.7, 14.9]

40.0% faster

100 records

Default

7.13ms [6.65, 8.50]

1.75ms [1.74, 1.76]

75.5% faster

100 records

Compact

1.09ms [1.09, 1.11]

1.75ms [1.74, 1.79]

60.3% slower

100 records

Pretty

6.85ms [6.61, 8.47]

4.96ms [4.90, 5.03]

27.5% faster

1,000 records

Default

88.01ms [62.86, 100.53]

17.24ms [17.17, 17.30]

80.4% faster

1,000 records

Compact

10.90ms [10.87, 10.98]

17.21ms [17.15, 17.28]

57.8% slower

1,000 records

Pretty

88.83ms [63.04, 102.21]

50.46ms [49.61, 51.15]

43.2% faster

Output Checks

Compact output stayed byte-for-byte identical across revisions. Default and pretty sizes differ because default now returns compact bytes and lexical formatting replaces minidom formatting.

Workload

Compact bytes, both

Default type/bytes, before → after

Pretty type/bytes, before → after

Small

134

str/142 → bytes/134

str/142 → str/146

100 records

57,049

str/64,551 → bytes/57,049

str/64,551 → str/69,952

1,000 records

571,005

str/646,007 → bytes/571,005

str/646,007 → str/700,008

The compact-output SHA-256 values are 013c28cd992bb5caa9a3c357a492ab47ccf00ad479ad3010e7ec376754cb06a8 for small, 05d79278d394c70a816fce23aa60e2fd0059233abf94c863a26e17af4dcdb090 for 100 records, and 72de1be56cbc381c25606a3ca5c73b40d482ee08ef2d74f93d3b2798e531d760 for 1,000 records.

Default calls are roughly 4-5x faster because they now return compact serializer bytes. Explicit pretty output was 27-42% faster in this run because lexical indentation replaced DOM parsing; the later change below removed that formatter entirely.

Explicit compact output is 45-61% slower. Its bytes were identical and the serializer was unchanged, isolating the regression mainly to the new full-input resource-budget scan.

CPython 3.15.0rc1 reproduces the same tradeoff: default and pretty output improve substantially, while explicit compact conversion pays for the security scan.

PyPy 3.10.16 corroborated the tradeoff: default calls were 73-87% faster, pretty calls were 42-72% faster, and compact calls were 31-35% slower.

Generation-Time Pretty Printing Benchmark

This August 23, 2026 comparison supersedes the lexical-formatter results above for pretty output. It measures the same public Json2xml(...).to_xml() path before and after indentation moved into the serializer itself.

Previously pretty=True serialized compact XML and then re-tokenized that output to insert indentation, a second full pass over generated markup. The serializer now receives an indent unit and emits indentation as it writes, so the tokenizer is gone and no XML is parsed at any point.

It compares 78ea9ea with 7b29472 on Apple Silicon and macOS 26.6.1 using CPython 3.14.6, driven by the same benchmarks/benchmark_security_hardening.py harness, payloads, worker order, and 68-samples-per-cell methodology described above.

Workload

Mode

Before

After

Change

Small

Default

6.4us [6.4, 6.5]

6.5us [6.5, 6.6]

1.5% slower

Small

Compact

6.5us [6.4, 6.5]

6.6us [6.6, 6.7]

2.3% slower

Small

Pretty

15.2us [15.1, 15.3]

8.2us [8.1, 8.2]

46.2% faster

100 records

Default

1.69ms [1.68, 1.70]

1.68ms [1.68, 1.70]

0.4% faster

100 records

Compact

1.68ms [1.67, 1.69]

1.69ms [1.68, 1.71]

0.5% slower

100 records

Pretty

4.86ms [4.81, 4.90]

2.36ms [2.35, 2.37]

51.4% faster

1,000 records

Default

16.75ms [16.63, 16.88]

16.55ms [16.44, 16.69]

1.2% faster

1,000 records

Compact

16.64ms [16.59, 16.73]

16.76ms [16.57, 16.87]

0.7% slower

1,000 records

Pretty

49.40ms [48.70, 49.90]

23.18ms [23.00, 23.37]

53.1% faster

Pretty conversion is roughly twice as fast, and the gain grows with payload size because the removed pass was proportional to output length. Compact and default calls move within measurement noise, which is the intended result: the compact writer keeps its previous hot path and treats the structural calls as plain writes.

Generation-Time Output Checks

All nine cells produced identical SHA-256 values before and after, pretty output included, so the speedup is not paid for with changed output. Note that these payloads contain no null values or empty containers; an empty element such as <a type="null"></a> is the one case whose pretty layout did change, since the old tokenizer split it across two lines.

Key Observations

Rust remains the best option for Python library calls. It avoids process overhead and is about 4-14x faster than the optimized pure Python path in this run.

Recent pure Python improvements substantially reduced conversion time. Medium and large inputs are roughly an order of magnitude faster than the April 2026 baseline, so relative Rust speedups are lower even though Rust is still fastest.

Go and Zig remain useful for native CLI workflows. They are slower for small and medium inputs because startup dominates, but both beat Python on the 323KB workload when full CLI process time is measured.

See docs/rust_memory_benchmark.rst for the June 2026 Rust peak-memory comparison between the previous extension-boundary copy and the Python bytes-writer implementation.

When to Use Each Implementation

Use Case

Recommended

Why

Python library calls

Rust

4-14x faster, no process overhead

Small files via CLI

Zig

Fastest startup among native CLIs in this run

Large files via CLI

Go or Zig

Both are faster than Python at 323KB

Batch processing

Go or Rust

Choose based on shell vs Python integration

Pure Python required

Python

Always available

Running the Benchmarks

Run benchmarks from a clean checkout with the project installed in an isolated environment.

uv venv
source .venv/bin/activate
uv pip install -e .
python benchmarks/benchmark_all.py

To reproduce the security-hardening public-API comparison on the active Python interpreter, run the committed harness. It removes its detached temporary worktrees without changing the current checkout.

python benchmarks/benchmark_security_hardening.py \
  --before 826439f \
  --after 48dfd38 \
  --output-json /tmp/json2xml-security-benchmark.json

For Rust benchmarks, install the extension into the same environment.

uv pip install maturin
cd rust
maturin develop --release
cd ..

For native CLI benchmarks, install the external tools and verify that the commands are visible.

go install github.com/vinitkumar/json2xml-go@latest
which json2xml-go
which json2xml-zig