Libraries Run Rust Inside Python (With PyO3), but the Boundary Sets the Speed
Updated: 1 day ago
PyO3 entered a fresh developer debate after one parser example showed why native speed alone does not guarantee a faster Python library.
The September 13 demonstration explains how libraries run Rust inside Python with PyO3, using a hand-built JSON parser as its test case. Rust parses the input first. PyO3 then converts the resulting tree into dictionaries, lists, strings, numbers, booleans, and Python exceptions.
That second step creates the conflict. A fast Rust algorithm can finish before the integration layer completes its work. For large results, converting native values into Python objects can consume more time than the operation developers intended to accelerate.
This is not a new Python capability or a new PyO3 release. CPython has supported native extensions for decades, including modules written in C, C++, and Fortran. The current interest reflects how Rust has made that old architecture attractive to another generation of library authors.
Pydantic, Polars, cryptography, and other projects have already put Rust behind familiar Python interfaces. Their success pressures maintainers to reconsider slow internal paths, but it also raises harder questions about packaging and platform coverage.
The important contest is therefore not Rust versus Python. It is native computation versus boundary overhead. That contest determines which ports produce meaningful gains and which merely move complexity into a compiled package.
Libraries Run Rust Inside Python With PyO3 Through a Familiar Import
PyO3 turns compiled Rust into a native extension that CPython can import, without asking application developers to abandon Python syntax.
Bob Belderbos demonstrated the path with a JSON parser written in Rust and exposed through a Python function. His parser walkthrough reduces the process to four stages.
A developer writes an ordinary Rust module, adds PyO3 attributes, builds the package with maturin, and imports the resulting extension from Python. Maturin is a build and packaging tool for Rust-based Python modules.
The compiled artifact is native machine code inside a shared library. Depending on the operating system, that file commonly ends with .so, .dylib, or .dll. Python loads it through the same broad extension mechanism used by older native modules.
That wording matters. Python does not interpret Rust source code at runtime. The Rust compiler produces machine code, and CPython calls exported functions through its native application binary interface. For developers building data-driven applications, Python libraries for database management can complement native extensions by providing convenient interfaces for connecting to, querying, and managing databases.
PyO3 supplies the binding layer. Its macros generate much of the glue needed for function calls, reference management, argument extraction, return values, and exception handling.
In the demonstration, #[pyfunction] marks a Rust function that Python can call. The #[pymodule] macro defines the extension module that Python initializes during import.
The public experience remains ordinary Python. A caller imports a module and passes a string to a parsing function. Nothing in that interaction requires the caller to understand Rust ownership, traits, lifetimes, or Cargo.
The implementation takes a different route. The input crosses from a Python string into a Rust string reference. Rust performs the parsing and constructs an enum representing the JSON tree.
An enum is a Rust type that can hold one of several defined variants. In this case, those variants represent nulls, booleans, numbers, strings, arrays, and objects.
The resulting tree initially belongs entirely to Rust. Python cannot use that structure directly because its interpreter expects objects governed by Python’s memory and type systems.
PyO3’s official guide describes both directions supported by the project. Developers can create Python modules in Rust, or embed a Python interpreter inside a Rust application.
The first direction drives this particular story. It lets maintainers preserve a Python-facing interface while relocating selected work into compiled code.
That model is already common across the Python ecosystem. NumPy established the larger pattern by presenting convenient Python operations backed by native computation. PyO3 changes the language and tooling used to build the extension, not the fundamental architecture.
The latest discussion matters because it makes the hidden boundary visible. The interesting event is not that Python suddenly learned to execute native code. It is that more maintainers can now build these extensions without manually writing every layer of CPython glue.
That lower implementation barrier expands the set of functions worth considering for a native port. It does not remove the need to measure the complete call, including everything that enters and leaves Rust.
Python Maintainers Face Pressure to Move Hot Paths Into Rust
Successful Rust-backed packages have turned native extensions from a specialist technique into a credible maintenance strategy for mainstream Python projects.
Pydantic provides the clearest reference point. Its second major version moved validation into pydantic-core, a separate package implemented in Rust.
The project’s early Pydantic V2 design said the rewritten core delivered major performance gains over the first version. Those figures came from Pydantic’s own prerelease benchmarks, so workloads still determine the result.
The architectural signal matters more than one benchmark. Python code defines models and generates schemas. The Rust core executes validation and serialization along the performance-sensitive path.
Polars applies a broader version of the same pattern. Its core is written in Rust and exposed through interfaces for several languages, including Python.
The project’s Polars architecture keeps query planning, columnar operations, and parallel execution close to native data structures. Python users still write expressions and receive familiar high-level results.
These projects pressure maintainers of parsers, validators, tokenizers, compression tools, database clients, and data engines. Users now know a Python package can retain its approachable interface while replacing selected internals.
The pressure is not simply about benchmark rankings. Native code can reduce CPU time, improve throughput, and make more predictable use of memory for well-defined operations.
Rust adds another appeal for maintainers. Its ownership and type systems catch categories of memory errors during compilation, although unsafe code and dependency defects remain possible.
PyO3 also provides conversions between many common Python and Rust types. It translates Rust errors into Python exceptions and participates in Python reference management.
That tooling can make an extension easier to maintain than a handwritten C interface. It does not make native integration automatic, but it narrows the amount of custom machinery required.
The result is a different build-versus-buy decision for maintainers. Previously, a slow Python function might remain in Python because a native rewrite demanded scarce C expertise.
Now a team with Rust experience can expose a smaller native core through PyO3. Maturin can then build the extension and place it inside a Python package.
This route works best when a narrow function consumes compact input, performs substantial computation, and returns a compact result. Compression, hashing, parsing summaries, validation, and numerical kernels often fit that shape.
It works less predictably when a function repeatedly crosses the language boundary. Many tiny calls can lose time to argument checking, dispatch, allocation, and conversion.
A large result creates a related problem. The algorithm may execute efficiently in Rust, yet the caller still expects ordinary Python objects.
That expectation turns data representation into the deciding factor. A library that keeps columnar buffers in native memory has a different cost profile from a parser returning thousands of nested dictionaries.
Maintainers are therefore being pushed toward architectural decisions, not merely language rewrites. They must decide which side owns the data and when Python objects should exist.
This pressure will continue because users compare end-to-end behavior. They care about the time between calling a function and receiving a usable result, not the isolated speed of its inner loop.
The Return Trip Can Erase the Native Speed Gain
The central mechanism is object materialization: converting a large Rust result into Python objects can dominate the completed operation.
Belderbos’s parser first creates a Rust tree containing every value in the JSON document. That stage can benefit from Rust’s compiled execution and explicit memory model.
The next stage walks the entire tree again. Each Rust object becomes a Python dictionary, each array becomes a list, and each leaf becomes a Python value.
This process is called materialization. It creates the concrete Python objects that the caller expects to inspect, mutate, serialize, or pass elsewhere.
PyO3’s IntoPyObject trait coordinates that conversion. A trait defines behavior that types can implement, letting each JSON variant describe its corresponding Python representation.
The conversion is recursive. An object requires a new dictionary, then entries for every key and value. An array requires a list containing converted children.
Each Python object also enters CPython’s memory-management system. Allocation, type metadata, and reference counting carry costs that did not exist in the Rust tree.
Traditional CPython builds add another constraint. Code touching Python objects generally needs interpreter access associated with the global interpreter lock, or GIL.
The GIL allows only one thread to drive the traditional Python interpreter at a time. Rust work detached from Python objects can run without holding that lock.
PyO3 documents a Python::detach mechanism for releasing interpreter access while Rust performs independent computation. Its parallel execution guide explains how other Python threads can then proceed.
That technique helps only while the Rust operation stays away from Python objects. Rebuilding a Python dictionary or list brings execution back to the interpreter boundary.
The parser example estimates that a document containing 100,000 values requires roughly the same order of Python object creations. This is an illustrative relationship, not a universal performance benchmark.
Shape matters as much as size. A flat numeric buffer can sometimes cross the boundary through a shared representation. A deeply nested document demands more object allocation and pointer traversal.
Call frequency creates another axis. One native call processing a large contiguous buffer can amortize its setup cost. Thousands of calls processing single values often cannot.
Error handling also crosses the boundary. A Rust parsing error needs to become a Python exception with the expected class, message, and location information.
PyO3 can map a typed Rust error into PyErr. A malformed string can therefore appear to the Python caller as ValueError, while an unavailable file can become FileNotFoundError.
That translation is valuable because it protects the public API. Users should not need a separate error model simply because maintainers changed the implementation language.
However, preserving Python semantics creates work. A native rewrite must reproduce edge cases, exception types, iteration behavior, ownership rules, and sometimes subclass interactions.
The real optimization target is therefore the complete interface. Parsing faster does not help enough when representation conversion remains unchanged and dominates the wall-clock result.
Libraries have several architectural responses. They can return smaller summaries, expose iterators, process callbacks in batches, or keep an opaque Rust-backed object alive.
A lazy view is especially relevant to large trees. Instead of constructing every Python object immediately, the extension can materialize only values that the caller requests.
This design reduces unnecessary conversion when an application reads a small portion of the result. It also shifts complexity into object lifetime, caching, mutation, and API design.
Columnar libraries can avoid some costs by retaining data in contiguous native buffers. Python receives a lightweight object that refers to the underlying storage instead of duplicating each element.
That strategy helps explain why Polars represents a stronger native architecture than a mechanical function-by-function rewrite. Its Python interface controls a Rust engine that keeps substantial work and data together.
A parser returning ordinary dictionaries faces a less favorable boundary. Its output format requires the extension to create the very object graph that Python code expects.
The lesson is not that PyO3 conversion is unusually inefficient. Any foreign-function interface must reconcile representations, lifetimes, errors, and ownership between its two sides.
PyO3 makes those obligations easier to express. It cannot repeal their cost.
Rust and Python Are Partners, but Packaging Keeps Score
A fast extension creates a distribution obligation because compiled wheels must match operating systems, processors, interpreters, and binary interfaces.
Pure Python packages have an enormous portability advantage. A single generic wheel can often run across operating systems and processor architectures.
Compiled extensions produce platform-specific machine code. Package maintainers must provide compatible artifacts or ask users to compile the project locally.
A wheel is Python’s built distribution format. It contains installable package files and carries compatibility tags for the interpreter, application binary interface, and platform.
The Python packaging guide describes the default matrix. Maintainers often need builds covering Python versions, operating systems, and architectures.
Continuous integration can automate much of that work. Maturin and related tooling can build and publish wheels, while projects such as cibuildwheel coordinate builds across target environments.
Automation does not eliminate testing. A wheel can install successfully yet fail because of an unsupported CPU feature, missing system library, or incompatible runtime expectation.
Linux creates particular complexity because distributions ship different versions of system components. Manylinux specifications give projects standardized build environments for broadly compatible wheels.
Windows and macOS require their own artifacts. Apple’s split between Intel and Arm hardware added another dimension, even though universal binaries can sometimes combine architectures.
The stable ABI can reduce the interpreter-version dimension. An ABI is the low-level contract that governs how compiled code calls into a binary runtime.
CPython’s original abi3 stable ABI lets qualifying extensions target multiple Python 3 versions with one wheel per platform and architecture. The extension must restrict itself to the Limited API.
That restriction trades some API access and potential optimization for broader compatibility. PyO3 supports selecting an appropriate minimum Python version when building an abi3 extension.
The packaging problem is evolving again with free-threaded CPython. Free-threaded builds remove the traditional GIL configuration, changing assumptions made by native extensions.
Current PyO3 documentation distinguishes traditional abi3 wheels from the newer stable ABI path for free-threaded builds. Maintainers must validate which interpreter configurations their artifacts actually support.
Platform questions appeared quickly in the public discussion around the parser article. Developers asked whether Rust-backed dependencies still work everywhere that Python can run.
The short answer is no. A compiled dependency works where its maintainers publish a compatible wheel or where users can build one with a supported toolchain.
That limitation also applies to native extensions written in other languages. Rust changes the available compiler targets and build dependencies, but it does not create the basic portability problem.
The cryptography package illustrates the user experience. Its documentation says most users receive a prebuilt wheel and do not need Rust installed.
Users outside the published wheel set may need a Rust compiler and other native dependencies. That fallback can surprise people who expected pip install to remain language-neutral.
Browser-hosted Python presents another edge. Pyodide runs Python through WebAssembly, so conventional desktop and server wheels do not automatically work there.
The ecosystem has made progress on WebAssembly builds for packages containing Rust. Pydantic-core now offers relevant artifacts, according to links raised in the Hacker News discussion.
Still, WebAssembly support requires deliberate packaging and testing. Input and output behavior can also encounter browser limitations involving threads, files, sockets, and asynchronous runtimes.
Mobile systems, embedded environments, unusual processors, and older enterprise distributions create similar pressure. A pure Python fallback can protect coverage, but maintaining two implementations adds work.
Library teams must therefore price portability into every native rewrite. The code path can be faster while the project becomes harder to distribute across its full audience.
For popular packages, that trade can be worth accepting. A large contributor base and mature release pipeline can support an extensive wheel matrix.
Smaller projects face a different equation. Native builds can turn a compact library into a release-engineering commitment across several environments.
PyO3 and maturin reduce that commitment substantially. They do not erase it, and users experience every unbuilt target as an installation failure.
The Skeptical Case Starts With End-to-End Measurement
“Written in Rust” is an implementation fact, not a performance result, and maintainers need benchmarks that include conversion and consumption.
Rust often improves the speed of CPU-intensive code compared with an equivalent Python loop. That comparison alone says little about an application’s completed workload.
An extension call includes argument conversion, validation, native dispatch, computation, output conversion, allocation, and error handling. The caller may then transform the result again.
A useful benchmark measures that whole route. It starts with the representation used by the application and ends with data the next stage can consume.
Microbenchmarks still have a role. They identify which stage consumes time and reveal whether an algorithm improved after a code change.
They become misleading when teams present only the fastest internal stage. A parser throughput figure can hide the cost of constructing a large Python object graph afterward.
Benchmarks also need representative inputs. Small documents can overstate fixed call overhead, while uniform synthetic data can miss allocation patterns found in production.
Warm caches, release builds, compiler flags, and CPU features can change results. Comparisons should keep those conditions visible and test the same public behavior.
Correctness deserves equal weight. Parsers and validators encounter malformed encodings, extreme nesting, duplicate keys, numeric edge cases, and unexpected resource use.
A port that changes exceptions or accepts different input can be faster because it performs different work. Compatibility tests must accompany performance tests.
Memory consumption can reverse an apparent win. Holding both a complete Rust tree and a newly materialized Python tree can temporarily require two representations.
Streaming or lazy designs can reduce that duplication. They can also change when errors occur and how long native buffers remain allocated.
Concurrency claims require similar caution. Rust code can use multiple threads, and PyO3 can release interpreter access during suitable native work.
The extension cannot safely manipulate ordinary Python objects from arbitrary native threads. It must return to PyO3’s interpreter rules whenever it interacts with them.
Free-threaded Python changes part of this picture, but it does not remove synchronization from application data. Thread safety remains an explicit library responsibility.
Security also resists simple slogans. Rust’s safe subset prevents several categories of memory misuse, but native extensions can contain unsafe blocks and vulnerable dependencies.
A defect in compiled code can crash the interpreter process rather than raise a normal Python exception. That consequence applies across native extension languages.
The strongest case for PyO3 is therefore specific. It lets maintainers combine a Python interface with Rust implementations where computation and data layout justify the boundary.
The weakest case is a cosmetic rewrite. Replacing concise Python code with native machinery offers little when the function performs limited work or spends most of its time in I/O.
A project should first profile the existing application. It should identify a stable hot path, define compatibility requirements, and measure the size and shape of exchanged values.
The team can then prototype one boundary. If conversion dominates, the next decision concerns data representation rather than parser instructions or compiler optimization.
This skeptical test does not argue against Rust-backed Python. It protects the approach from becoming a default answer to every performance complaint.
A successful native extension hides its implementation from ordinary users. Installation works, exceptions remain recognizable, type hints stay useful, and performance improves in the actual workload.
Users should not need to celebrate the language choice. They should simply notice that their existing Python operation finishes sooner.
Three Signals Will Show Where PyO3 Goes Next
The next phase depends on boundary-aware APIs, broader wheel coverage, and extensions that work across both traditional and free-threaded Python.
The first signal is a shift in public benchmarks. More projects should report end-to-end timings that include object creation, not only native kernel throughput.
That shift would strengthen the case for PyO3 because it would connect language choice to observable application results. It would also expose ports whose gains disappear during conversion.
Look for benchmarks that compare multiple return designs. A native-backed view, iterator, batch result, and fully materialized dictionary can produce very different outcomes.
Memory profiles should appear beside timing results. They can show whether an extension briefly holds duplicate Rust and Python structures.
The second signal is broader wheel coverage. Successful projects will publish reliable artifacts for major operating systems, processor families, and supported Python versions.
WebAssembly deserves special attention. Better tooling and more PyPI-hosted WebAssembly wheels would reduce the portability objection raised by browser-based Python users.
Mobile and less common Linux architectures remain useful tests. Each supported target expands the practical meaning of a normal Python dependency.
Projects should also document their source-build path. A missing wheel becomes less damaging when error messages, compiler requirements, and reproducible build instructions are clear.
If native packages repeatedly drop targets that pure Python versions supported, the portability concern grows stronger. If build automation closes those gaps, it weakens.
The third signal is stable support for free-threaded CPython. Extensions must adapt their assumptions about interpreter locking, shared state, and object access.
PyO3’s evolving APIs and stable ABI support will shape that transition. Maintainers need more than successful compilation; they need concurrency testing under realistic workloads.
A mature result would let one project support traditional and free-threaded interpreters without multiplying release complexity beyond control.
Failure would look different. Fragmented wheel sets, unclear ABI tags, or hidden serialization bottlenecks would make Rust-backed packages harder to trust as default dependencies.
The broader direction remains convincing. Python supplies a large user base, readable orchestration, and a productive application layer. Rust supplies compiled kernels, explicit data structures, and safer native tooling.
However, the partnership succeeds only when the boundary becomes part of the design. Libraries run Rust inside Python with PyO3, but users consume Python objects and Python package artifacts.
Developers evaluating a port should begin with one concrete question: what must cross that boundary after the fast Rust function returns?
Profile that return path before committing to a rewrite. Test the wheel on every promised target. Then compare the full operation with the Python implementation users already depend on.
If the native version still wins, PyO3 has earned its place. If conversion consumes the gain, change the interface before changing more code.



