From Belderbos · Bob Belderbos · · 3m
How Rust rides inside Python
A tiny JSON parser in Rust shows where PyO3 ports actually get slow, and it is not the parsing.
In brief
A tiny JSON parser in Rust shows where PyO3 ports actually get slow, and it is not the parsing. When you port Rust into Python, profile the boundary. Building Python objects on the way out can cost more than the algorithm itself. Originally reported by Belderbos.
You already run Rust

Every Pydantic v2 validation call hands the work to a Rust extension. Its core, pydantic-core, is built with PyO3, the same toolchain used to build anything here.
“Every time you validate data with Pydantic v2 ... a Rust extension does the work.”
A parser small enough to read
The example is a JSON parser written in Rust and exposed to Python so you can import it like any package. The last step, building the Python objects, is the one that matters.
Four steps to import

Write a normal Rust module. Annotate it with PyO3 macros. Let maturin compile and install it. Then import it.
Macros are the wiring
#[pyfunction] and #[pymodule] work like Python decorators. They rewrite the function they sit on, adding the glue for calls, type conversion and reference counting at the boundary.
“A Rust attribute macro is close to a Python decorator”
Maturin does the plumbing
Maturin compiles the crate to a shared library.so.dylib or .dll, and drops it in your virtualenv. No packaging steps, import just works.
Scalars are the easy demo
The earlier tutorial returned a single number. The interesting problems only start once your function returns a structure instead of one value.
A Rust enum as a tree
The parser returns a plain JsonValue enum: Null, Boolean, Number, String, Array, Object. Each variant carries its own data, so a JSON tree maps onto it cleanly.
Python never sees it
That tree lives entirely on the Rust side. The PyO3 layer sits on top as a thin adapter, which is why the handoff deserves its own attention.
“That tree lives entirely in Rust. Python never sees it.”
Two lines to expose
parse_json takes a Python token and a &str, returns PyResult<Bound<PyAny>>, and does parse(input)?.into_pyobject(py). Real work on the left, object building on the right.
Reading the signature
Python<'py> is your access token to the interpreter. Bound<'py, PyAny> is a handle to any Python object. PyResult means value or exception, and ? propagates the failure.
The return trip costs
into_pyobject walks the whole tree and rebuilds it: a dict per object, a list per array, a float or str per leaf. 100,000 values means 100,000 Python objects, all after parsing finishes.
“this materialization loop, not the parsing, can dominate the end-to-end time”
Don't build what nobody reads
One From impl turns a typed Rust error into a real ValueError with the offset. Then stop materializing trees the caller won't fully touch; hand back a lazy, Rust-backed view instead.
“Getting Rust to run fast is the easy half.”
What to remember
When you port Rust into Python, profile the boundary. Building Python objects on the way out can cost more than the algorithm itself.

