Skip to content
Learn Rust
Theme

Use Rust from Python

Python can import a compiled Rust library like a Python file. This page builds one, wordstats, with PyO3 (the Rust side) and maturin (the build tool), and calls it from Python. It ran with PyO3 0.30.1 and maturin 1.15.0 in the image behind this site:

terminal
$ python3 --version
Python 3.13.5
$ rustc --version
rustc 1.99.1 (58cf8f9c3 2026-10-13)
$ cargo --version
cargo 1.99.1 (5f94df478 2026-08-27)
$ python3 -c "import sys; print('GIL enabled:', sys._is_gil_enabled())"
GIL enabled: True

Every step that downloads something (get-pip.py, maturin, the PyO3 crates) is labelled needs network.

RememberPyO3 turns Rust functions and structs into Python functions and classes, and maturin builds the file that Python imports.

Project layout
wordstats/
├── py/                         Python scripts that call it
│   ├── calls.py
│   ├── counter.py
│   ├── gil.py
│   ├── mistakes.py
│   ├── panic.py
│   └── try_exception.py
├── src/
│   └── lib.rs                  the Rust code on this page
├── Cargo.lock                  created by Cargo
├── Cargo.toml                  a cdylib that depends on PyO3
└── pyproject.toml              tells Python tools to build with maturin
Rust
[package]
name = "wordstats"
version = "0.1.0"
edition = "2024"
[lib]
name = "wordstats"
crate-type = ["cdylib"]
[dependencies]
pyo3 = "=0.30.1"

crate-type = ["cdylib"] asks for a shared library, the kind of file Python can load. pyo3 = "=0.30.1" pins one exact version.

One function per idea on this page:

Rustsrc/lib.rs
//! Word counting written in Rust, called from Python.
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use std::collections::BTreeMap;
use std::sync::{Condvar, Mutex};
use std::time::Duration;
/// Add two integers.
#[pyfunction]
fn add(a: i64, b: i64) -> i64 {
a + b
}
/// Upper-case the text and add an exclamation mark.
#[pyfunction]
fn shout(text: &str) -> String {
format!("{}!", text.to_uppercase())
}
/// Count the words in `text`, ignoring case. The dict is sorted by word.
#[pyfunction]
fn word_counts(text: &str) -> BTreeMap<String, usize> {
let mut counts = BTreeMap::new();
for word in text.split_whitespace() {
*counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
counts
}
/// Sum a list of floats.
#[pyfunction]
fn total(values: Vec<f64>) -> f64 {
values.iter().sum()
}
/// Parse "3,4" into (3, 4). A bad input raises ValueError.
#[pyfunction]
fn parse_pair(text: &str) -> PyResult<(i64, i64)> {
let (left, right) = text
.split_once(',')
.ok_or_else(|| PyValueError::new_err(format!("expected 'a,b', got {text:?}")))?;
// `?` turns a ParseIntError into a Python ValueError.
Ok((left.trim().parse()?, right.trim().parse()?))
}
/// A Rust panic, to see what Python gets.
#[pyfunction]
fn explode(text: &str) -> i32 {
text.parse().unwrap()
}
/// A word counter that lives in Rust and is used like a Python object.
#[pyclass]
struct Counter {
counts: BTreeMap<String, usize>,
}
#[pymethods]
impl Counter {
#[new]
fn new() -> Self {
Counter { counts: BTreeMap::new() }
}
fn add(&mut self, word: &str) {
*self.counts.entry(word.to_lowercase()).or_insert(0) += 1;
}
/// Add every count from `other` into this counter.
fn merge(&mut self, other: PyRef<'_, Counter>) {
for (word, n) in &other.counts {
*self.counts.entry(word.clone()).or_insert(0) += n;
}
}
/// The `n` most frequent words, most frequent first, ties by word.
fn top(&self, n: usize) -> Vec<(String, usize)> {
let mut all: Vec<_> = self.counts.iter().map(|(w, c)| (w.clone(), *c)).collect();
all.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
all.truncate(n);
all
}
#[getter]
fn distinct(&self) -> usize {
self.counts.len()
}
fn __repr__(&self) -> String {
format!("Counter({} distinct words)", self.counts.len())
}
}
// A flag that one thread can wait for and another can set.
static FLAG: (Mutex<bool>, Condvar) = (Mutex::new(false), Condvar::new());
fn wait_for_flag(timeout_ms: u64) -> bool {
let (lock, signal) = &FLAG;
let guard = lock.lock().unwrap();
let (guard, _) = signal
.wait_timeout_while(guard, Duration::from_millis(timeout_ms), |set| !*set)
.unwrap();
*guard
}
#[pyfunction]
fn reset_flag() {
*FLAG.0.lock().unwrap() = false;
}
#[pyfunction]
fn set_flag() {
*FLAG.0.lock().unwrap() = true;
FLAG.1.notify_all();
}
/// Wait for the flag while still holding the GIL.
#[pyfunction]
fn wait_holding_gil(timeout_ms: u64) -> bool {
wait_for_flag(timeout_ms)
}
/// Wait for the flag with the GIL released.
#[pyfunction]
fn wait_detached(py: Python<'_>, timeout_ms: u64) -> bool {
py.detach(|| wait_for_flag(timeout_ms))
}
#[pymodule]
mod wordstats {
#[pymodule_export]
use super::{
Counter, add, explode, parse_pair, reset_flag, set_flag, shout, total, wait_detached,
wait_holding_gil, word_counts,
};
}

#[pyfunction] marks a function for Python, and the #[pymodule] block at the bottom lists what the module contains (PyO3: modules). Older tutorials add a pyo3/extension-module feature; the PyO3 guide says projects are encouraged to migrate off that feature (its FAQ calls it “now deprecated”), and that maturin 1.9.4 and later sets the PYO3_BUILD_EXTENSION_MODULE variable for you.

A plain python3 -m venv .venv fails in this image (no ensurepip), so the page makes the environment without pip and adds pip with get-pip.py, as the Python phrasebook does. Elsewhere, python3 -m venv .venv and pip install maturin should be enough to start (not run here), given Rust.

terminal
$ python3 -m venv .venv   # fails in this image: no ensurepip
The virtual environment was not created successfully because ensurepip is not
available.  On Debian/Ubuntu systems, you need to install the python3-venv
package using the following command.

    apt install python3.13-venv

You may need to use sudo with that command.  After installing the python3-venv
package, recreate your virtual environment.

Failing command: /home/you/wordstats/.venv/bin/python3
$ python3 -m venv --without-pip .venv
$ curl -O https://bootstrap.pypa.io/get-pip.py && .venv/bin/python get-pip.py pip==26.2.2   # needs network; get-pip.py is not pinned
$ .venv/bin/pip install maturin==1.15.0   # needs network; maturin has its own dependencies, not pinned
$ .venv/bin/maturin develop   # needs network: downloads PyO3 and its dependencies
🐍 Found CPython 3.13 at /home/you/wordstats/.venv/bin/python
🔗 Found pyo3 bindings

📦 Built wheel for CPython 3.13 to /tmp/.tmpZJyTDF/wordstats-0.1.0-cp313-cp313-linux_aarch64.whl
✏️ Setting installed package as editable
🛠 Installed wordstats-0.1.0

This output changes from run to run. This is one real run.

maturin develop builds the crate and installs it into the virtual environment. Its docs say it builds in debug mode by default. The Built wheel line names a temporary folder, so the output varies.

A plain cargo build stops at the link step in this image. PYO3_BUILD_EXTENSION_MODULE=1, the variable maturin sets, lets it finish:

terminal
$ cargo build   # filtered to the linker complaint; needs network: downloads PyO3 and its dependencies
cannot find -lpython3.13
$ PYO3_BUILD_EXTENSION_MODULE=1 cargo build   # needs network if run on its own
Pythonpy/calls.py
import wordstats
print(wordstats.add(2, 3))
print(wordstats.shout("héllo, wörld"))
print(wordstats.word_counts("the cat saw the other cat and The dog"))
print(wordstats.total([1.5, 2.5, 3.0]))
print(wordstats.total([1, 2, 3]))
print(wordstats.add.__doc__)
print(wordstats.word_counts(""))
print(type(wordstats.word_counts("a")))
terminal
$ .venv/bin/python py/calls.py
5
HÉLLO, WÖRLD!
{'and': 1, 'cat': 2, 'dog': 1, 'other': 1, 'saw': 1, 'the': 3}
7.0
6.0
Add two integers.
{}
<class 'dict'>
  • A Python int became an i64, and a str a &str that Rust upper-cased (é became É).
  • The BTreeMap<String, usize> came back as a dict in the map’s sorted order. The Vec<f64> parameter accepted [1, 2, 3], so an int converts to f64 (PyO3’s table).
Pythonpy/mistakes.py
import wordstats
def attempt(label, call):
try:
print(label, "->", call())
except Exception as e:
print(label, "->", type(e).__name__ + ":", e)
attempt("add(2**63, 1)", lambda: wordstats.add(2**63, 1))
attempt("add('1', 2)", lambda: wordstats.add("1", 2))
attempt("total('123')", lambda: wordstats.total("123"))
attempt("total([1, 'x'])", lambda: wordstats.total([1, "x"]))
attempt("shout('\\ud800')", lambda: wordstats.shout("\ud800"))
attempt("parse_pair('3, 4')", lambda: wordstats.parse_pair("3, 4"))
attempt("parse_pair('34')", lambda: wordstats.parse_pair("34"))
attempt("parse_pair('3,x')", lambda: wordstats.parse_pair("3,x"))
terminal
$ .venv/bin/python py/mistakes.py
add(2**63, 1) -> OverflowError: Python int too large to convert to C long
add('1', 2) -> TypeError: 'str' object cannot be interpreted as an integer
total('123') -> TypeError: Can't extract `str` to `Vec`
total([1, 'x']) -> TypeError: must be real number, not str
shout('\ud800') -> UnicodeEncodeError: 'utf-8' codec can't encode character '\ud800' in position 0: surrogates not allowed
parse_pair('3, 4') -> (3, 4)
parse_pair('34') -> ValueError: expected 'a,b', got "34"
parse_pair('3,x') -> ValueError: invalid digit found in string

The first five failures happen while PyO3 converts an argument, before the body of the function runs. A number too big for i64 is an OverflowError, and a str where an int belongs is a TypeError. A str is refused as a Vec although it is iterable. A lone surrogate such as "\ud800" is a valid Python string but not valid UTF-8, so it cannot become a &str.

For your own failures, return a PyResult. parse_pair builds a ValueError itself for '34'. For '3,x' the ? converted a ParseIntError: PyO3 accepts any error type with From<E> for PyErr, and that one has it (PyO3: error handling; Errors covers From and ? in plain Rust).

A panic is a different case. explode calls unwrap on a failed parse:

terminal
$ .venv/bin/python py/panic.py
thread '<unnamed>' (561) panicked at src/lib.rs:49:18:
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
only BaseException caught it: PanicException
(<class 'pyo3_runtime.PanicException'>, <class 'BaseException'>, <class 'object'>)
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }

This output changes from run to run. This is one real run.

Rust’s panic hook printed the message, and Python received a PanicException. It derives from BaseException, so except Exception did not catch it; the PyO3 docs say this is “so that it will typically propagate all the way through the stack and cause the Python interpreter to exit”. See Errors for panics.

Counter in the file above is a #[pyclass], with a #[new] constructor, a #[getter] and a __repr__.

Pythonpy/counter.py
from wordstats import Counter
c = Counter()
for word in "the cat saw the other cat and The dog".split():
c.add(word)
print(c)
print(c.distinct)
print(c.top(2))
other = Counter()
other.add("dog")
other.add("dog")
c.merge(other)
print(c.top(3))
try:
c.merge(c)
except RuntimeError as e:
print("RuntimeError:", e)
try:
c.distinct = 5
except AttributeError as e:
print("AttributeError:", e)
terminal
$ .venv/bin/python py/counter.py
Counter(6 distinct words)
6
[('the', 3), ('cat', 2)]
[('dog', 3), ('the', 3), ('cat', 2)]
RuntimeError: Already mutably borrowed
AttributeError: attribute 'distinct' of 'builtins.Counter' objects is not writable

distinct has a getter and no setter, so assigning to it is an AttributeError. The RuntimeError is the interesting line. c.merge(c) passes the same object as &mut self and as the other argument, which Rust’s borrowing rules forbid. The compiler cannot see that call, so PyO3 checks the rule at run time, in a way its guide calls “very similar to std::cell::RefCell<T>” (RefCell). The same page says a #[pyclass] type must be Send and Sync (Send and Sync).

The PyO3 guide says CPython “was limited by the global interpreter lock (GIL), which only allowed a single thread to drive the Python interpreter at a time” (PyO3: parallelism). While your Rust function runs, it holds that lock, and no other Python thread runs. Python::detach releases the lock for the length of a closure. Before 0.26.0 it was allow_threads, so older tutorials use the old name (migration guide).

The two wait_* functions at the end of lib.rs wait for a flag with a timeout, and differ only in py.detach. The script gives them 1,000 ms. A helper thread sets the flag after 0.1 seconds, and needs the lock to do so:

Pythonpy/gil.py
import threading
import time
import wordstats
def helper():
time.sleep(0.1)
wordstats.set_flag()
def run(wait):
wordstats.reset_flag()
t = threading.Thread(target=helper)
t.start()
got_it = wait(1000)
t.join()
return got_it
print("wait_holding_gil:", run(wordstats.wait_holding_gil))
print("wait_detached: ", run(wordstats.wait_detached))
terminal
$ .venv/bin/python py/gil.py
wait_holding_gil: False
wait_detached:    True

With the lock held, the helper could not run, so the wait timed out and returned False. With detach, the helper ran in the meantime and the wait returned True. The result shows who could run, not how long anything took (it assumes an idle machine). The versions session above shows that this CPython has the GIL; I did not run a free-threaded build.

What may go into the closure is limited. A Bound<PyList> carries a Python token, which on this build stands for holding the lock, so it cannot cross:

$ cargo check # needs network: downloads PyO3 and its dependencies
error[E0277]: `*mut pyo3::Python<'static>` cannot be shared between threads safely
   --> src/lib.rs:7:15
    |
  7 |     py.detach(|| items.len())
    |        ------ ^^^^^^^^^^^^^^ `*mut pyo3::Python<'static>` cannot be shared between threads safely
    |        |
    |        required by a bound introduced by this call
    |
    = help: within `pyo3::Bound<'_, PyList>`, the trait `Sync` is not implemented for `*mut pyo3::Python<'static>`
note: required because it's used within this closure
   --> src/lib.rs:7:15
    |
  7 |     py.detach(|| items.len())
    |               ^^
    = note: required for `{closure@src/lib.rs:7:15: 7:17}` to implement `Ungil`
  ... (notes pointing into library source code trimmed)

The docs for detach say “Only types that implement Ungil can cross the closure” (Ungil is the marker for types that may leave the lock behind). The fix is to copy the Python list into plain Rust data first, here a Vec<String> parameter, then move that into the closure:

Rustdetach_fixed.rs
use pyo3::prelude::*;
/// Length of a list, computed with the GIL released.
#[pyfunction]
fn slow_len(py: Python<'_>, items: Vec<String>) -> usize {
py.detach(move || items.len())
}
#[pymodule]
mod wordstats {
#[pymodule_export]
use super::slow_len;
}
$ cargo check # needs network: downloads PyO3 and its dependencies

These two use cargo check because of the link error above.

maturin develop is for your own machine. A wheel is the file pip installs; maturin build makes one:

terminal
$ .venv/bin/maturin build --release -o dist   # needs network: downloads PyO3 and its dependencies
🐍 Found CPython 3.13 at /usr/bin/python3
🔗 Found pyo3 bindings

📦 Built wheel for CPython 3.13 to dist/wordstats-0.1.0-cp313-cp313-manylinux_2_34_aarch64.whl
$ python3 -m zipfile -l dist/wordstats-0.1.0-*.whl
wordstats-0.1.0-cp313-cp313-manylinux_2_34_aarch64.whl
File Name                                             Modified             Size
wordstats/__init__.py                          1980-01-01 00:00:00          119
wordstats/wordstats.cpython-313-aarch64-linux-gnu.so 1980-01-01 00:00:00       770832
wordstats-0.1.0.dist-info/METADATA             1980-01-01 00:00:00           76
wordstats-0.1.0.dist-info/WHEEL                1980-01-01 00:00:00          110
wordstats-0.1.0.dist-info/sboms/wordstats.cyclonedx.json 1980-01-01 00:00:00        16905
wordstats-0.1.0.dist-info/RECORD               1980-01-01 00:00:00          513

This output changes from run to run. This is one real run.

maturin build --release makes an optimised wheel. The name follows the wheel tag format: cp313 is CPython 3.13 for both the Python tag and the ABI tag, and manylinux_2_34_aarch64 is the platform tag, which the spec says should work on distributions with glibc 2.34 or newer. A fresh environment installs the wheel and imports it from site-packages:

terminal
$ python3 -m venv --without-pip fresh   # then pip install dist/*.whl into it (needs network for pip itself)
Package   Version
--------- -------
pip       26.2.2
wordstats 0.1.0
$ fresh/bin/python -c "import wordstats; print(wordstats.__file__); print(wordstats.add(2, 3))"
/home/you/wordstats/fresh/lib/python3.13/site-packages/wordstats/__init__.py
5

Not run: what publishing needs. The maturin docs say that to publish Linux wheels on PyPI you use a manylinux Docker image or build with zig. The PyO3 guide describes abi3 wheels, usable on several Python versions. This wheel was built outside a manylinux image, so the page makes no claim about where it will run.

A second route skips PyO3: export a plain C function and let Python call it:

Rustsrc/lib.rs
/// A plain C function: no Python anywhere.
#[unsafe(no_mangle)]
pub extern "C" fn wordstats_add(a: i64, b: i64) -> i64 {
a + b
}

#[unsafe(no_mangle)] keeps the function’s name in the library; the Rust Reference says that from the 2024 edition the attribute must be marked unsafe (no_mangle); the C and C++ page goes further. Two Python tools can load the resulting libcabi.so:

Python
import ctypes
import sys
lib = ctypes.CDLL(sys.argv[1])
# ctypes has not been told the signature, so it guesses C int (32 bits).
print("no signature:", lib.wordstats_add(2**40, 1))
lib.wordstats_add.argtypes = [ctypes.c_int64, ctypes.c_int64]
lib.wordstats_add.restype = ctypes.c_int64
print("with signature:", lib.wordstats_add(2**40, 1))
$ cargo build
$ python3 py/use_ctypes.py target/debug/libcabi.so
no signature: 1
with signature: 1099511627777
$ pip install cffi==2.1.1 # in a virtual environment made the same way; needs network
$ python py/use_cffi.py target/debug/libcabi.so
cffi: 1099511627777
OverflowError: int too big to convert
$ python3 -c "import sysconfig, os; print('Python.h present:', os.path.exists(sysconfig.get_paths()['include'] + '/Python.h'))"
Python.h present: False
Tool How it knows the signature Run on this page
PyO3 and maturin The Rust types, checked by the compiler Yes, above
ctypes (standard library) You set argtypes and restype in Python Yes
cffi, ABI mode You write a cdef string in Python Yes, installed with pip
cffi, API mode, and Cython A C compiler builds a module against Python’s headers Not run

The ctypes docs say “their value is masked to fit into the C type” and, without argtypes, “the number of arguments is not checked”. So 2**40 was cut to 32 bits and the answer was 1; once the signature was declared, it was right. The cffi run printed the full 1099511627777, and rejected 2**63 with an OverflowError, since it checks values against the declared int64_t. The cffi docs say ABI mode “will crash if you call some function or access some fields of a structure that was slightly misdeclared in the cdef()”. Its API mode and Cython go through a C compiler, which needs Python.h, and the image has none.

With ctypes or cffi you describe the Rust function again in Python, and nothing checks that description against the Rust code (ctypes without argtypes cut 2**40 above). With PyO3 the signature is Rust code and the macro writes the conversions. The C-ABI route has upsides: the cabi crate built with plain cargo build, and its library does not name a Python version, while the PyO3 wheel is tagged cp313 and the PyO3 guide says modules by default “can only be used with the same Python version they were compiled against”. ABI means application binary interface, the binary-level contract between compiled code. The same question for other targets has its own page: Node, C and C++ and WebAssembly.

All three ran in the image, in one session that builds the environment again (needs network).

  1. Release build. Save the output of py/calls.py after .venv/bin/maturin develop and again after maturin develop --release, and diff them. The diff printed nothing and its exit code was 0, and target had both debug and release folders. This page makes no claim about speed.
  2. Return an Option. Copy try_option.rs over src/lib.rs, run .venv/bin/maturin develop, then print(wordstats.longest(['a', 'abc', 'ab'])) and print(wordstats.longest([])) in Python. The first printed abc, and the empty list gave None.
  3. Your own exception. Copy try_exception.rs over src/lib.rs, run .venv/bin/maturin develop, then run py/try_exception.py. create_exception! makes EmptyText, a subclass of ValueError. Catching it by name worked, and isinstance(e, ValueError) was True.
Rust
use pyo3::prelude::*;
/// The longest word, or None for an empty list.
#[pyfunction]
fn longest(words: Vec<String>) -> Option<String> {
words.into_iter().max_by_key(|w| w.chars().count())
}
#[pymodule]
mod wordstats {
#[pymodule_export]
use super::longest;
}
Pythonpy/try_exception.py
import wordstats
try:
wordstats.count_words(" ")
except wordstats.EmptyText as e:
print(type(e).__name__ + ":", e)
print("is a ValueError:", isinstance(e, ValueError))
print(wordstats.count_words("one two three"))
terminal
$ maturin develop; python py/calls.py > debug.txt; maturin develop --release; python py/calls.py > release.txt; diff debug.txt release.txt; ls target   # needs network: downloads PyO3 and its dependencies
🐍 Found CPython 3.13 at /home/you/wordstats/.venv/bin/python
🔗 Found pyo3 bindings
📦 Built wheel for CPython 3.13 to /tmp/.tmpDDx8mh/wordstats-0.1.0-cp313-cp313-linux_aarch64.whl
✏️ Setting installed package as editable
🛠 Installed wordstats-0.1.0
🐍 Found CPython 3.13 at /home/you/wordstats/.venv/bin/python
🔗 Found pyo3 bindings
📦 Built wheel for CPython 3.13 to /tmp/.tmpzNzPdU/wordstats-0.1.0-cp313-cp313-linux_aarch64.whl
✏️ Setting installed package as editable
🛠 Installed wordstats-0.1.0
diff exit code: 0
CACHEDIR.TAG  debug  maturin  release
$ cp tries/option.rs src/lib.rs; maturin develop; python -c "import wordstats; print(wordstats.longest(['a', 'abc', 'ab'])); print(wordstats.longest([]))"   # needs network: downloads PyO3 and its dependencies
🐍 Found CPython 3.13 at /home/you/wordstats/.venv/bin/python
🔗 Found pyo3 bindings
📦 Built wheel for CPython 3.13 to /tmp/.tmpUx9CgK/wordstats-0.1.0-cp313-cp313-linux_aarch64.whl
✏️ Setting installed package as editable
🛠 Installed wordstats-0.1.0
abc
None
$ cp tries/exception.rs src/lib.rs; maturin develop; python py/try_exception.py   # needs network: downloads PyO3 and its dependencies
🐍 Found CPython 3.13 at /home/you/wordstats/.venv/bin/python
🔗 Found pyo3 bindings
📦 Built wheel for CPython 3.13 to /tmp/.tmpMzdaLI/wordstats-0.1.0-cp313-cp313-linux_aarch64.whl
✏️ Setting installed package as editable
🛠 Installed wordstats-0.1.0
EmptyText: no words in the text
is a ValueError: True
3

This output changes from run to run. This is one real run.