Libraries Run Rust Inside Python (With PyO3)
Recorded: Sept. 13, 2026, 4:09 p.m.
| Original | Summarized |
How Libraries Run Rust Inside Python (with PyO3) — Bob Belderbos | Developer coach and builder belderbos.dev Blog Coaching 1:1 Coaching Apps Platforms About How Libraries Run Rust Inside Python (with PyO3) Every time you validate data with Pydantic v2, the data-validation library most Python apps reach for, a Rust extension does the work. Its core, pydantic-core, is built with PyO3, the same toolchain we'll use here. This post builds that same kind of bridge, small enough to read in one sitting: a JSON parser written in Rust, exposed to Python, so you can import it like any other package. The last step, turning the Rust result into Python objects, is the one to understand before you port anything: for a parser like this, it can cost more than the parsing itself. Write a normal Rust module. #[pyfunction] and #[pymodule] are the two Rust macros that do the wiring. A Rust attribute macro is close to a Python decorator: it rewrites the function it sits on, here adding the glue that lets Python call it and handles the type conversions and reference counting at the boundary. py: Python<'py> is a token representing access to the Python interpreter and is what you pass to PyO3 APIs that need access to Python objects. On traditional Python builds, this access is associated with holding the GIL. PyO3 hands it to you and you pass it along wherever you touch a Python object. So parse(input)? does the real work, and .into_pyobject(py) builds the Python objects the caller asked for. That last call is where the cost lives: it has to create Python objects for the nodes in the tree, and on a large document that can add up to more work than the parse itself. Learning Rust? I co-run a 6-week Python to Rust cohort where you build a performant JSON parser with PyO3 bindings. rust Share: Get my free guide What Developers Should Never Outsource to AI: three real case studies on using AI without giving up the judgment that makes you an engineer. Then emails on Python, Rust, and AI. Subscribe Keep reading Guardrails Protect Your Codebase. What Protects Your Judgment? Learning New Skills in the AI Era (vBrownBag) Rust, AI, and the Developer Mindset (Develpreneur Podcast) belderbos.dev LinkedIn © 2026 Bob Belderbos. esc |
The integration of Rust into Python is facilitated by tools that create a bridge between the two languages, most notably through the PyO3 framework, which enables Rust code to be exposed to Python. This process involves a structured four-step procedure: writing a standard Rust module, annotating it with PyO3 macros, compiling the crate using Maturin to generate a shared library, and finally importing the resulting code into Python. The PyO3 macros, specifically #[pyfunction] and #[pymodule], serve as the mechanism for this integration, functioning similarly to Python decorators by handling the necessary glue for Python to call Rust functions, manage type conversions, and handle reference counting at the interface boundary. When dealing with complex data structures, such as a JSON parser implemented in Rust, the primary challenge shifts to the costly step of translating the native Rust representation into native Python objects when data crosses the boundary. In this context, the parsing itself might be highly optimized, but the subsequent materialization of the data structure into Python objects can become the performance bottleneck, potentially dominating the total execution time, especially for large documents. A Rust parser typically outputs a structured enumeration, effectively representing the JSON tree internally within Rust, which Python does not directly observe. The interface layer, managed by PyO3, handles the exposure of this data. Exposing functions requires careful management of Python and Rust types. The signature often includes elements like Python<'py>, which represents access to the Python interpreter, and Bound<'py, PyAny>, which acts as a handle to a Python object. The conversion process, implemented via traits such as IntoPyObject, dictates how Rust values are mapped to their Python equivalents. For instance, converting a nested structure like a JSON tree involves recursively walking the Rust structure and constructing corresponding Python equivalents, such as dictionaries for objects and lists for arrays. This materialization loop, where potentially tens of thousands of Rust values must be converted into individual Python objects, is where significant overhead accrues. Error handling is also managed across this boundary using Rust's trait system. Errors originating in Rust, such as a JsonError, must be translated into appropriate Python exceptions, such as a ValueError, carrying relevant contextual data like error positions. This translation is achieved through From implementations, allowing Rust errors to seamlessly manifest as standard Python exceptions when an operation fails. Architecturally, this cost analysis leads to an important consideration for porting Rust code. If the Rust function only returns simple scalar values, the boundary cost is generally negligible. However, if the function returns large, complex structures, optimizing the transformation becomes critical. Instead of fully materializing the entire data structure into Python objects immediately, a more performant approach involves returning a lazy, Rust-backed view of the data. This strategy defers the actual creation of Python objects until they are explicitly requested by the caller, thereby avoiding the upfront cost of processing and creating potentially massive numbers of Python objects, which is the key to ensuring that the performance gains from using Rust are not negated by the overhead of the interface layer. |