Build a Python library with a Rust extension
Python is flexible and productive, but sometimes a function needs to run faster than Python allows. Rust gives you that speed with memory safety and no garbage collector. This tutorial walks you through creating a Python package where the performance-critical code lives in Rust, compiled into a native extension module that Python imports like any other module.
You’ll use three tools together:
- uv manages the Python project, dependencies, and virtual environment
- maturin is the build backend that compiles Rust code into a Python extension module
- PyO3 is the Rust library that bridges Rust and Python, letting you write Rust functions callable from Python
Prerequisites
- uv installed (installation guide)
- Rust installed via rustup
Verify both are working:
$ uv --version
uv 0.11.6 (Homebrew 2025-04-01)
$ rustc --version
rustc 1.94.1 (e408947bf 2026-03-25)
Creating the Project
Start by creating a Python library project with uv init --lib:
uv init --lib string_utils
cd string_utilsThis creates a standard Python library layout. You’ll restructure it to add Rust source code alongside the Python code.
Delete the generated src/ directory since you’ll replace it with Rust source code:
rm -rf srcConfiguring the Build System
Replace the contents of pyproject.toml with a configuration that tells uv to use maturin as the build backend:
[project]
name = "string-utils"
version = "0.1.0"
description = "A Python library with Rust extensions"
readme = "README.md"
requires-python = ">=3.10"
[build-system]
requires = ["maturin>=1.0,<2.0"]
build-backend = "maturin"
[tool.maturin]
features = ["pyo3/extension-module"]
module-name = "string_utils._core"
python-source = "python"Three things to note in [tool.maturin]:
features = ["pyo3/extension-module"]enables PyO3’s extension module support, required for building importable.so/.pydfilesmodule-name = "string_utils._core"tells maturin the compiled Rust code should be importable asstring_utils._corepython-source = "python"tells maturin to look for Python source files in apython/directory rather thansrc/
Setting Up the Rust Side
Create a Cargo.toml in the project root. This is Rust’s equivalent of pyproject.toml:
[package]
name = "string_utils"
version = "0.1.0"
edition = "2021"
[lib]
name = "_core"
crate-type = ["cdylib"]
[dependencies]
pyo3 = { version = "0.24", features = ["extension-module"] }The crate-type = ["cdylib"] line tells Rust to compile a C-compatible dynamic library, which is what Python loads as an extension module.
Now create the Rust source file:
mkdir srcuse pyo3::prelude::*;
/// Count the number of words in a string.
#[pyfunction]
fn word_count(text: &str) -> usize {
text.split_whitespace().count()
}
/// Reverse a string.
#[pyfunction]
fn reverse(text: &str) -> String {
text.chars().rev().collect()
}
/// Check if a string is a palindrome (ignoring case and whitespace).
#[pyfunction]
fn is_palindrome(text: &str) -> bool {
let cleaned: String = text.chars().filter(|c| !c.is_whitespace()).collect();
let lower = cleaned.to_lowercase();
lower == lower.chars().rev().collect::<String>()
}
/// A Python module implemented in Rust.
#[pymodule]
fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(word_count, m)?)?;
m.add_function(wrap_pyfunction!(reverse, m)?)?;
m.add_function(wrap_pyfunction!(is_palindrome, m)?)?;
Ok(())
}Each #[pyfunction] attribute marks a Rust function that PyO3 will expose to Python. The #[pymodule] function defines the module itself and registers each function in it.
The module function name _core matches the name = "_core" in Cargo.toml and the module-name = "string_utils._core" in pyproject.toml.
Adding the Python Wrapper
Create a python/string_utils/ directory with an __init__.py that re-exports the Rust functions:
mkdir -p python/string_utilsfrom string_utils._core import word_count, reverse, is_palindrome
__all__ = ["word_count", "reverse", "is_palindrome"]This gives users a clean import path (from string_utils import word_count) while keeping the compiled Rust module as an implementation detail behind _core.
Building and Testing
Run uv sync to compile the Rust code and install the package into your virtual environment:
$ uv sync
Using CPython 3.12.10
Creating virtual environment at: .venv
Resolved 1 package in 16ms
Building string-utils @ file:///root/string_utils
Installed 1 package in 21s
+ string-utils==0.1.0
The first build takes longer because Cargo downloads and compiles PyO3 and its dependencies. Subsequent builds reuse the cached artifacts in the target/ directory.
Now test the functions:
$ uv run python -c "
from string_utils import word_count, reverse, is_palindrome
print(word_count('hello world'))
print(reverse('hello'))
print(is_palindrome('racecar'))
print(is_palindrome('Race Car'))
"
2
olleh
True
True
Project Structure
Your final project layout looks like this:
-
- pyproject.toml
- Cargo.toml
- Cargo.lock
- README.md
-
- lib.rs
-
-
- init.py
-
-
src/lib.rscontains the Rust implementationpython/string_utils/__init__.pyprovides the Python interfaceCargo.tomlconfigures the Rust buildpyproject.tomlconfigures the Python package and tells uv to use maturintarget/holds Rust build artifacts (add to.gitignore)
Next Steps
From here you can:
- Add more complex Rust functions that process large datasets or perform CPU-intensive computation
- Write tests with pytest that exercise both the Rust functions and the Python wrapper
- Publish the package to PyPI (maturin handles building platform-specific wheels)