PyO3 Error Handling: Map Rust Errors to Python
Errors are inevitable. When Rust code detects a problem, it must communicate it to Python in a way Python developers expect: as an exception that can be caught, logged, and handled. PyO3's PyErr type and PyResult<T> wrapper automate this translation, converting Rust's error types (Result<T, E>) into Python exceptions that are caught with try/except. This article teaches you to handle errors gracefully, design custom error types, propagate errors through the call stack, and provide informative messages.
A well-designed error strategy prevents panics, clarifies intent for the caller, and makes debugging straightforward. In PyO3, there is no ambiguity: functions either return a value or raise an exception. Your job is to decide which Rust errors map to which Python exceptions, then implement that mapping consistently.
Basic Error Handling with PyResult<T>
PyResult<T> is an alias for Result<T, PyErr>. Return it from any function exposed to Python that can fail:
use pyo3::prelude::*;
#[pyfunction]
fn divide(a: i32, b: i32) -> PyResult<f64> {
if b == 0 {
return Err(PyErr::new::<pyo3::exceptions::PyZeroDivisionError, _>(
"cannot divide by zero"
));
}
Ok(a as f64 / b as f64)
}
#[pymodule]
fn math_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(divide, m)?)?;
Ok(())
}
From Python:
from math_ext import divide
print(divide(10, 2)) # Output: 5.0
try:
divide(10, 0)
except ZeroDivisionError as e:
print(f"Error: {e}") # Output: Error: cannot divide by zero
The PyErr::new::<ExceptionType, _>() constructor maps a Rust error to a Python exception type. PyO3 provides built-in exception types for common cases: PyZeroDivisionError, PyValueError, PyTypeError, PyIndexError, and many others.
Custom Error Types and the From Trait
If your Rust code uses a custom error type, implement From<MyError> for PyErr to enable automatic conversion:
use pyo3::prelude::*;
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
InvalidValue(String),
MissingKey(String),
}
// Implement conversion from ConfigError to PyErr
impl From<ConfigError> for PyErr {
fn from(err: ConfigError) -> Self {
match err {
ConfigError::InvalidValue(msg) => {
PyErr::new::<pyo3::exceptions::PyValueError, _>(msg)
}
ConfigError::MissingKey(key) => {
PyErr::new::<pyo3::exceptions::PyKeyError, _>(
format!("Missing configuration key: {}", key)
)
}
}
}
}
#[pyfunction]
fn load_config(key: String, value: String) -> PyResult<String> {
if key.is_empty() {
return Err(ConfigError::MissingKey("key".to_string()).into());
}
if value.is_empty() {
return Err(ConfigError::InvalidValue("value cannot be empty".to_string()).into());
}
Ok(format!("{}={}", key, value))
}
#[pymodule]
fn config_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(load_config, m)?)?;
Ok(())
}
From Python:
from config_ext import load_config
print(load_config("db_host", "localhost")) # Output: db_host=localhost
try:
load_config("", "localhost")
except KeyError as e:
print(f"Error: {e}") # Output: Error: 'Missing configuration key: key'
try:
load_config("db_port", "")
except ValueError as e:
print(f"Error: {e}") # Output: Error: value cannot be empty
The .into() call invokes the From trait, converting ConfigError to PyErr automatically. This pattern scales to large codebases: define your error type once, implement From, and use ? to propagate.
Propagating Errors with the ? Operator
The ? operator in Rust calls into() and returns early. Combining ? with From<T> for PyErr allows clean error propagation:
use pyo3::prelude::*;
#[pyfunction]
fn parse_and_multiply(s: String, factor: i32) -> PyResult<i32> {
let num: i32 = s.parse()
.map_err(|_| PyErr::new::<pyo3::exceptions::PyValueError, _>(
format!("'{}' is not a valid integer", s)
))?;
Ok(num * factor)
}
#[pymodule]
fn parse_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(parse_and_multiply, m)?)?;
Ok(())
}
If parse() fails, the error is automatically wrapped in PyErr and returned to Python. No explicit error handling code is needed.
Mapping Standard Rust Errors
Rust's standard library errors (like std::io::Error) should also be converted. Implement From for these as well:
use pyo3::prelude::*;
use std::fs;
#[pyfunction]
fn read_file(path: String) -> PyResult<String> {
fs::read_to_string(&path)
.map_err(|e| PyErr::new::<pyo3::exceptions::PyOSError, _>(
format!("Failed to read '{}': {}", path, e)
))
}
#[pymodule]
fn file_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(read_file, m)?)?;
Ok(())
}
From Python:
from file_ext import read_file
try:
content = read_file("/nonexistent/file.txt")
except OSError as e:
print(f"Error: {e}") # Output: Error: Failed to read '/nonexistent/file.txt': ...
Exception Types Reference
PyO3 provides a full suite of Python exception types. Here are the most common:
| Python Exception | PyO3 Type | Use Case |
|---|---|---|
ValueError | PyValueError | Invalid argument value |
TypeError | PyTypeError | Unexpected type (e.g., integer expected, string given) |
ZeroDivisionError | PyZeroDivisionError | Division or modulo by zero |
IndexError | PyIndexError | Index out of range |
KeyError | PyKeyError | Missing dictionary key |
OSError | PyOSError | File system or OS errors |
RuntimeError | PyRuntimeError | Unclassified runtime error |
NotImplementedError | PyNotImplementedError | Feature not yet available |
Custom Python Exception Classes
For domain-specific errors, define a custom Python exception and raise it from Rust:
use pyo3::prelude::*;
use pyo3::create_exception;
create_exception!(my_module, ValidationError, pyo3::exceptions::PyException);
#[pyfunction]
fn validate_email(email: String) -> PyResult<()> {
if !email.contains('@') {
return Err(ValidationError::new_err("Email must contain '@'"));
}
Ok(())
}
#[pymodule]
fn validation_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(validate_email, m)?)?;
m.add("ValidationError", m.py().get_type_bound::<ValidationError>())?;
Ok(())
}
From Python:
from validation_ext import validate_email, ValidationError
try:
validate_email("invalid_email")
except ValidationError as e:
print(f"Validation failed: {e}") # Output: Validation failed: Email must contain '@'
Error Messages Best Practices
Write error messages that are actionable and informative:
- Bad:
"Error in function." - Good:
"Failed to parse configuration key 'db_timeout': expected integer, got 'slow'"
Include the operation, the input that failed, and the expected type or range. This helps Python developers debug quickly.
Key Takeaways
PyResult<T>is the return type for fallible functions exposed to Python.PyErr::new::<ExceptionType, _>()creates exceptions; use built-in types when possible.- Implement
From<YourError> for PyErrto enable automatic conversion with?and.into(). - The
?operator propagates errors cleanly; use it instead of explicit pattern matching when possible. - Map Rust errors to familiar Python exception types so Python developers can catch them idiomatically.
- Custom exception classes (via
create_exception!) are useful for domain-specific errors.
Frequently Asked Questions
What happens if I panic in a PyO3 function?
PyO3 catches panics and converts them to RuntimeError exceptions in Python. However, panicking is poor form; use PyResult<T> and return an Err instead. Panics make debugging harder and may leave resources in an inconsistent state.
Can I attach additional context or a cause to a PyErr?
PyO3 does not support exception chaining (Python 3's raise ... from ...) natively as of the latest stable version. The recommended approach is to include all context in the error message string.
Can I catch a Python exception inside Rust and convert it back to a Rust error?
Yes. Use PyErr::fetch() to retrieve the current exception or PyErr::is_instance() to check the type. See the PyO3 documentation on exception handling for advanced patterns.
What if I want to raise an exception in a class method?
The same PyResult<T> and PyErr::new::<...>() patterns apply. The method signature changes from fn(...) -> T to fn(...) -> PyResult<T>, and errors propagate as expected.
Is there a performance cost to error handling?
Creating a PyErr and propagating it incurs overhead (stack unwinding, memory allocation). However, this cost is negligible compared to Python's interpreter overhead. Do not avoid error handling for performance; correctness is paramount.