Skip to main content

Expose Python Functions with PyO3 and Maturin

A PyO3 function is a Rust function annotated with #[pyfunction] that Python can call directly. PyO3 automatically converts Python types to Rust and back—integers to i32, strings to String, lists to Vec, and so on. This article shows you how to expose functions with various parameter types and return values, handle optional arguments, and return multiple values. By the end, you will author functions that seamlessly bridge the Python–Rust boundary.

PyO3's type system is the workhorse of extension development. Every Python–Rust call crosses a type boundary, and PyO3 abstracts away the conversion cost and complexity. Understanding which Rust types map to which Python types, and when to use PyRef, PyObject, or raw references, is essential to writing efficient, bug-free extensions.

Basic Function Signature: Python Integers to Rust

The simplest case is a function that takes and returns numeric types. Here is a function that computes the sum of two integers:

use pyo3::prelude::*;

#[pyfunction]
fn add(a: i32, b: i32) -> i32 {
a + b
}

#[pymodule]
fn math_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(add, m)?)?;
Ok(())
}

From Python:

from math_ext import add
result = add(5, 3) # Returns 8

The #[pyfunction] macro generates code that intercepts the Python call, converts the Python integers to Rust i32 values, calls add, converts the result back to a Python integer, and returns it. This happens automatically and is transparent to both languages.

Working with Strings and Collections

Strings and collections require explicit type conversions. PyO3 provides wrapper types (String, Vec, HashMap) that simplify this. Here is a function that reverses a string:

use pyo3::prelude::*;

#[pyfunction]
fn reverse_string(s: String) -> String {
s.chars().rev().collect()
}

#[pymodule]
fn string_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(reverse_string, m)?)?;
Ok(())
}

And a function that sums a list of integers:

#[pyfunction]
fn sum_list(numbers: Vec<i32>) -> i32 {
numbers.iter().sum()
}

From Python, these behave as you would expect:

from string_ext import reverse_string
from sum_ext import sum_list

print(reverse_string("hello")) # Output: "olleh"
print(sum_list([1, 2, 3, 4, 5])) # Output: 15

Optional Parameters with Option<T>

Many functions benefit from optional parameters. Rust's Option type maps directly to Python's keyword argument convention:

#[pyfunction]
fn greet(name: String, greeting: Option<String>) -> String {
let prefix = greeting.unwrap_or_else(|| "Hello".to_string());
format!("{}, {}!", prefix, name)
}

#[pymodule]
fn greeting_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(greet, m)?)?;
Ok(())
}

From Python:

from greeting_ext import greet

print(greet("Alice")) # Output: "Hello, Alice!"
print(greet("Bob", greeting="Hi")) # Output: "Hi, Bob!"

If you omit the keyword argument, PyO3 passes None (which Rust interprets as Option::None); if you provide it, Rust receives Option::Some(value).

Returning Multiple Values as Tuples

A Rust function can return a tuple, and PyO3 converts it to a Python tuple automatically:

#[pyfunction]
fn div_with_remainder(dividend: i32, divisor: i32) -> (i32, i32) {
(dividend / divisor, dividend % divisor)
}

#[pymodule]
fn math_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(div_with_remainder, m)?)?;
Ok(())
}

From Python:

from math_ext import div_with_remainder

quotient, remainder = div_with_remainder(17, 5) # quotient=3, remainder=2

Default Arguments with #[pyo3(signature = (...)]

For more control, use the #[pyo3(signature = (...))] attribute to specify default values:

#[pyfunction]
#[pyo3(signature = (name, times=1))]
fn repeat_name(name: String, times: u32) -> String {
(0..times).map(|_| &name).collect::<Vec<_>>().join(", ")
}

#[pymodule]
fn repeat_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(repeat_name, m)?)?;
Ok(())
}

From Python:

from repeat_ext import repeat_name

print(repeat_name("Alex")) # Output: "Alex"
print(repeat_name("Alex", times=3)) # Output: "Alex, Alex, Alex"

Type Mapping Reference Table

PyO3 automatically converts between Python and Rust types. Here are the most common mappings:

Python TypeRust TypeNotes
inti32, i64, u32, u64Choose based on expected range.
floatf32, f64Precision loss with f32 is possible.
strString, &strString for owned values; &str for borrowed.
listVec<T>Element type must be convertible.
dictHashMap<String, V>Keys are always strings for simplicity.
tuple(T1, T2, ...)Rust tuples map directly.
boolboolStraightforward conversion.
NoneOption<T>None in Python = Option::None in Rust.

Error Handling in Functions

If your function can fail, use PyResult<T> instead of plain T:

use pyo3::prelude::*;

#[pyfunction]
fn parse_integer(s: String) -> PyResult<i32> {
s.parse::<i32>()
.map_err(|_| PyErr::new::<pyo3::exceptions::PyValueError, _>(
format!("'{}' is not a valid integer", s)
))
}

#[pymodule]
fn parse_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(parse_integer, m)?)?;
Ok(())
}

From Python:

from parse_ext import parse_integer

print(parse_integer("42")) # Output: 42
try:
parse_integer("not_a_number")
except ValueError as e:
print(f"Error: {e}") # Output: Error: 'not_a_number' is not a valid integer

Key Takeaways

  • #[pyfunction] macros handle type conversion automatically for built-in types.
  • Rust's Option<T> maps to Python's optional keyword arguments.
  • Tuples in Rust return as tuples in Python without extra boilerplate.
  • #[pyo3(signature = (...))] allows default arguments and fine-grained control.
  • PyResult<T> propagates Rust errors to Python as Python exceptions.
  • Refer to the type mapping table when deciding between i32, u64, String, and Vec.

Frequently Asked Questions

Can I use &str instead of String for parameters?

Yes. &str is more efficient (it is borrowed, not allocated) and works for immutable string parameters. Use &str when you do not need to modify the string or transfer ownership.

What happens if Python passes the wrong type, like a string to a function expecting an integer?

PyO3 raises a TypeError automatically. The error message clearly states what type was expected and what was received. No panic occurs; Python's exception handling works normally.

Can I expose generic Rust functions like fn add<T: Add>(a: T, b: T) -> T?

No. Python does not have Rust-style generics, so you must provide concrete, monomorphic functions (e.g., add_i32, add_f64). The alternative is to use PyAny and handle type checking manually, but that is more complex; see Article 9.

How do I handle large data structures efficiently?

For large collections, use Vec<T> and let PyO3 convert in-place. Avoid copying data unnecessarily. If a function takes a list but only reads it, use &[T] instead of Vec<T> to borrow the slice. For numerical data, NumPy arrays are more efficient; that is covered in Article 6.

Can a function take *args or **kwargs like Python functions?

Yes, using the #[pyo3(signature = (...))] attribute with *args or **kwargs. See the PyO3 documentation on function signatures for advanced patterns.

Further Reading