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)

Creating the Project

Start by creating a Python library project with uv init --lib:

uv init --lib string_utils
cd string_utils

This 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 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.

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:

$ 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.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

Please submit corrections and feedback...