PyO3 (Rust): Python Bindings vs ctypes & CFFI

Max WellsMax WellsFounder of Rustify

TL;DR: PyO3 is the standard Rust crate for Python interoperability in 2026. Use it when you want to speed up Python code with native Rust extensions or embed Python inside a Rust program. Choose PyO3 if you need real Rust performance in a Python ecosystem; choose pure Python only when performance and native packaging are not the bottleneck.


What Is PyO3?

PyO3 is the standard Rust crate for bindings to CPython, and it is the default choice in 2026 when you want Rust and Python to work together in one product.

Two main use cases:

Use case 1: Rust → Python (most common)
  Write hot code in Rust → expose as Python module → import in Python
 
Use case 2: Python → Rust (embedding)
  Rust binary → calls Python interpreter → runs Python scripts

This page matters most for Python engineers hitting performance ceilings, data and ML teams, and Rust developers who need to integrate with Python-heavy environments instead of replacing them outright.


How Do You Create a Python Module in Rust?

Annotate Rust functions with #[pyfunction] and register them with #[pymodule].

# Cargo.toml
[lib]
name = "my_module"
crate-type = ["cdylib"]  # required for Python extensions
 
[dependencies]
pyo3 = { version = "0.23", features = ["extension-module"] }
use pyo3::prelude::*;
 
#[pyfunction]
fn sum_numbers(a: f64, b: f64) -> f64 {
    a + b
}
 
#[pyfunction]
fn fibonacci(n: u64) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        _ => {
            let (mut a, mut b) = (0u64, 1u64);
            for _ in 2..=n {
                (a, b) = (b, a + b);
            }
            b
        }
    }
}
 
#[pymodule]
fn my_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_numbers, m)?)?;
    m.add_function(wrap_pyfunction!(fibonacci, m)?)?;
    Ok(())
}

When Should You Use PyO3?

Use PyO3 when Python is still part of the product boundary, but pure Python is no longer fast enough for the hottest code paths.

PyO3 is a strong fit when:

  • you need a Python package with a Rust performance core
  • your users still expect normal pip installation workflows
  • you are integrating Rust into an existing Python codebase
  • you want to keep business logic in Python while moving bottlenecks to Rust

PyO3 is a weaker fit when you want a fully standalone Rust application with no Python dependency surface, or when the problem is mostly I/O-bound and Rust will not materially change outcomes.


How Do You Build and Install the Python Package?

Use maturin; the official build tool for PyO3 projects.

pip install maturin
 
# New project
maturin new --bindings pyo3 my-module
cd my-module
 
# Develop mode (build + install in current virtualenv)
maturin develop
 
# Build a wheel for distribution
maturin build --release
 
# Publish to PyPI
maturin publish

After maturin develop:

# Python
import my_module
 
print(my_module.sum_numbers(3.0, 4.5))  # 7.5
print(my_module.fibonacci(10))           # 55

How Do You Expose Rust Structs as Python Classes?

Use #[pyclass] and #[pymethods] to create Python-compatible classes.

use pyo3::prelude::*;
 
#[pyclass]
struct Point {
    #[pyo3(get, set)]
    x: f64,
    #[pyo3(get, set)]
    y: f64,
}
 
#[pymethods]
impl Point {
    #[new]
    fn new(x: f64, y: f64) -> Self {
        Point { x, y }
    }
 
    fn distance_to(&self, other: &Point) -> f64 {
        ((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt()
    }
 
    fn __repr__(&self) -> String {
        format!("Point({}, {})", self.x, self.y)
    }
}
 
#[pymodule]
fn my_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_class::<Point>()?;
    Ok(())
}
from my_module import Point
 
p1 = Point(0.0, 0.0)
p2 = Point(3.0, 4.0)
print(p2.distance_to(p1))  # 5.0
print(repr(p1))             # Point(0, 0)

PyO3 vs ctypes or CFFI in 2026

Choose PyO3 in 2026 when you want Rust-native ergonomics and typed CPython bindings; choose ctypes or CFFI mainly for thinner legacy interop layers.

PyO3ctypes / CFFI
Primary useRust extension modulesForeign function interop
Ergonomics for Rust developersStrongWeaker
Python packaging storyBetter with maturinMore manual
Access to Python objectsNativeLower-level
Best fitRust-powered Python packagesThin existing C API bridges
2026 default recommendationYesOnly in narrower cases

If you are building a modern Rust-backed Python package, PyO3 is usually the right choice. If you already have a stable C ABI surface and only need a thin bridge, ctypes or CFFI can still be enough.


What Real-World Projects Use PyO3?

PyO3 matters because some of the most visible high-performance Python tools now rely on Rust under the hood.

ProjectWhat it does
pydantic-corePydantic v2's validation engine
ruffFast Python linter/formatter
polarsDataFrame library (Python bindings)
orjsonFast JSON library for Python
cryptographyCryptographic primitives
tokenizers (HuggingFace)Fast tokenization for ML

That list is the clearest practical signal that PyO3 is not a toy bridge. It is part of the real Python performance toolchain.


Why Does PyO3 Matter Professionally?

PyO3 matters because it lets a Rust engineer create value inside a Python-heavy company without asking the company to rewrite everything.

That makes it especially relevant in data platforms, ML tooling, developer tools, CLI products, and backend services with Python at the edges. Teams often do not need Rust everywhere. They need one performance-critical module to stop being slow, memory-heavy, or unsafe. PyO3 is often the answer.


Frequently Asked Questions

For CPU-bound work: typically 10–100x faster. The speedup depends on the algorithm; Rust eliminates Python's interpreter overhead, dynamic dispatch, and GIL contention. For I/O-bound code, the difference is much smaller.

Yes. PyO3 provides Python<'py> tokens that represent holding the GIL. To release the GIL for parallel Rust code, use py.allow_threads(|| { ... }).

Yes; embed the Python interpreter and call Python code:

Python::with_gil(|py| {
    let sys = py.import("sys")?;
    let version: String = sys.getattr("version")?.extract()?;
    println!("Python {version}");
    Ok::<_, PyErr>(())
});

maturin is the modern, recommended build tool; it handles everything automatically. setuptools-rust is older and requires more manual configuration. Use maturin for new projects.


Sources


  • WASM: Alternative way to run Rust in non-native environments
  • Unsafe: PyO3 internals use unsafe to interface with CPython
  • Ownership: PyO3 manages ownership across the Rust/Python boundary
  • Polars: The Python-facing Polars ecosystem relies on Rust bindings patterns similar to PyO3

Keep Reading

Ready to Land a $80-120k Rust Job?