Python PyO3 Tutorial: Build Your First Extension
A PyO3 extension is a compiled Rust library that Python can import and call directly—no subprocess, no serialization overhead, just raw native speed. In this article, you will create a minimal but complete extension: a single Rust function that computes the factorial of an integer, compile it into a Python module, and call it from Python code in under 15 minutes.
PyO3 is the Rust-to-Python FFI (foreign function interface) crate that handles type conversion, error marshaling, and the Global Interpreter Lock (GIL) so you don't have to. Maturin is the Python package that automates the build, linking, and distribution. Together, they make Rust extensions nearly as easy as writing native C extensions—but with Rust's safety guarantees.
Why Build Your First Extension Now?
Before diving into architecture, you need a concrete win: a working extension that proves the concept. This builds confidence and establishes a mental model for the remaining articles. The classic benchmark is factorial computation—it's CPU-bound, has no I/O, and clearly demonstrates that Rust outpaces Python once the function crosses the module boundary (calling Rust from Python has negligible overhead if you batch computation).
Prerequisites
You need Rust installed (via rustup), Python 3.7+, and pip. On Windows, macOS, and Linux, the installation is identical. If Rust is not yet on your machine, run curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh on Unix or download the installer from the official Rust website. Verify your setup by running rustc --version and python --version.
Creating a New Maturin Project
Maturin is distributed as a Python package. Install it globally:
pip install maturin
Once installed, maturin scaffolds a new mixed Rust–Python project in seconds:
maturin new factorial_ext --bindings pyo3
cd factorial_ext
This command creates a directory structure with the essentials: a Cargo.toml (Rust's manifest), a pyproject.toml (Python's manifest), and a stub src/lib.rs where your Rust code lives. The --bindings pyo3 flag tells maturin to use PyO3 for the FFI.
Writing Your First Function
Open src/lib.rs and replace the boilerplate with the following:
use pyo3::prelude::*;
#[pyfunction]
fn factorial(n: u32) -> u32 {
match n {
0 | 1 => 1,
_ => (2..=n).product(),
}
}
#[pymodule]
fn factorial_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(factorial, m)?)?;
Ok(())
}
Here is what each line does:
use pyo3::prelude::*;imports PyO3's main macros and types.#[pyfunction]is a macro that wraps thefactorialfunction so Python can call it. The macro generates boilerplate code to convert Python integers to Rustu32and back.#[pymodule]is another macro that registers the module itself with Python. The module name (factorial_ext) matches thelibname inCargo.toml.- Inside the module function,
m.add_function(wrap_pyfunction!(factorial, m)?)registers thefactorialfunction into the module's namespace.
The factorial function itself is pure Rust: it matches on n, returns 1 for base cases (0 and 1), and uses Rust's range iterator and product() method to compute the factorial iteratively. No Python-specific code is needed; PyO3 handles the bridge.
Building the Extension
In the project root, run:
maturin develop
This command compiles the Rust code and installs the extension into your current Python environment as an editable package. You will see Rust compiler output, then Installed factorial_ext.
Calling Your Extension from Python
Create a test script, test_factorial.py, in the project root:
from factorial_ext import factorial
print(f"factorial(5) = {factorial(5)}") # Output: factorial(5) = 120
print(f"factorial(10) = {factorial(10)}") # Output: factorial(10) = 3628800
import time
n = 10000
start = time.perf_counter()
result = factorial(n)
elapsed = (time.perf_counter() - start) * 1_000_000
print(f"factorial({n}) took {elapsed:.2f} µs")
Run the script:
python test_factorial.py
You will see the factorial results printed, and a timing in microseconds. The Rust version completes orders of magnitude faster than a pure-Python equivalent because the computation runs as native machine code with zero interpreter overhead.
Key Takeaways
- PyO3 is a Rust crate that exports a Python module directly; maturin automates the build and linking.
- The
#[pyfunction]and#[pymodule]macros handle all type conversion and GIL management. - A minimal PyO3 extension requires only a function signature, a macro annotation, and module registration.
- Building with
maturin developinstalls the compiled module into your Python path instantly. - Rust's iterative
product()method and zero-overhead abstraction make it ideal for compute-intensive tasks.
Frequently Asked Questions
What is the Global Interpreter Lock (GIL) and why does PyO3 handle it?
Python's GIL is a mutex that ensures only one thread executes bytecode at a time. It prevents memory corruption in the CPython interpreter itself. PyO3 releases the GIL automatically when your Rust code runs (if it does not call back into Python), so your extension scales across CPU cores without contention.
Can I run PyO3 extensions on PyPy, Conda, or other Python distributions?
PyO3 officially targets CPython (the standard Python distribution). PyPy, Conda, and other variants may have limited or no support. Always test your extension in your deployment environment.
What happens if my Rust function panics?
By default, a Rust panic is caught by PyO3 and converted to a Python RuntimeError. You can catch and handle this in Python with a standard try/except block. For fine-grained error control, use PyResult<T> instead of panicking; this is covered in Article 5.
Does the compiled extension run on all operating systems?
The compiled extension is specific to the OS and Python version it was built on. You must recompile or distribute pre-built wheels for each target (Linux, macOS, Windows) and Python version (3.7, 3.8, 3.9, etc.). Maturin automates this via CI/CD; packaging is covered in Article 8.