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)
If either command prints “command not found”, install the missing tool before continuing. For Rust, run curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh and restart your terminal.
Creating the Project
Start by creating a Python library project with uv init --lib:
$ uv init --lib string_utils
Initialized project `string-utils` at `/path/to/string_utils`
$ cd string_utils
This creates a standard Python library layout with a pyproject.toml, README.md, .python-version, and a src/string_utils/ package. You’ll replace that src/ directory with Rust source code in the next steps.
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. If these three names don’t match, the build will succeed but Python will raise ImportError when you try to import the module. If you hit an ImportError, check that all three files agree on _core.
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. If you see error: failed to run custom build command for 'pyo3-build-config', your Rust toolchain may be too old. Run rustup update and try again.
$ 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.
Notice the new target/ directory and Cargo.lock file that appeared in your project root. Rust uses Cargo.lock to pin dependency versions, similar to how uv uses uv.lock for Python dependencies.
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
is_palindrome('Race Car') returns True because the Rust implementation strips whitespace and compares case-insensitively. From Python’s perspective, these are ordinary functions in an ordinary module. The from string_utils import word_count import path works because __init__.py re-exports the Rust functions from the compiled _core extension.
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)