Skip to content

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

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 src

Configuring the Build System

Replace the contents of pyproject.toml with a configuration that tells uv to use maturin as the build backend:

pyproject.toml
[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/.pyd files
  • module-name = "string_utils._core" tells maturin the compiled Rust code should be importable as string_utils._core
  • python-source = "python" tells maturin to look for Python source files in a python/ directory rather than src/

Setting Up the Rust Side

Create a Cargo.toml in the project root. This is Rust’s equivalent of pyproject.toml:

Cargo.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 src
src/lib.rs
use 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_utils
python/string_utils/__init__.py
from 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.rs contains the Rust implementation
  • python/string_utils/__init__.py provides the Python interface
  • Cargo.toml configures the Rust build
  • pyproject.toml configures the Python package and tells uv to use maturin
  • target/ 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)

Learn More

Last updated on