| name | rust |
| description | Strict set of rules in terms of codebase development, design patterns, and best practices. Use when the user wants to develop a new feature or refactor existing code. |
Principles
Priority: Correctness > Safety > Readability > Performance
- Idiomatic Rust. Follow standard library conventions. If the stdlib does it one way, do it that way.
- Leverage the type system. Encode invariants in types, not runtime checks. Make illegal states unrepresentable.
- Domain-driven design. Name everything in the language of the problem, not the implementation.
- Reuse existing libraries and frameworks when possible.
Safety rules
Adapted from The Power of Ten (Holzmann) for Rust.
- Simple control flow. No nested if-else, no nested loops. Split into single-purpose functions. Max one level of branching per function body.
- Short functions. No function longer than ~60 lines. If it's too long, decompose it.
- Fixed loop bounds. All loops must have a provable upper bound. Prefer iterators (
for x in collection) over manual while i < len with index arithmetic.
- Minimal allocation. Prefer
&str over String, slices over Vec, borrows over clones. Allocate only when you must own.
- Assertions at boundaries. Use
debug_assert! for invariants within functions. Validate inputs at public API boundaries with Result/Option, not panics.
- Smallest possible scope. Variables, types, and functions should be visible only where needed. Prefer module-private by default; add
pub only when required.
- Handle all return values. Never discard
Result. Use ? propagation or explicit handling. Annotate intentional ignores with let _ =.
- Macros sparingly. Prefer generics, traits, and enums over procedural macros. Macros obscure control flow and complicate debugging.
- Domain types over raw primitives. Wrap repetitive low-level operations in a named type instead of scattering the same logic across functions.
let mut i = 0;
while i < bytes.len() {
if bytes[i] == target { return i; }
i += 1;
}
let mut scanner = Scanner::new(bytes);
while let Some(b) = scanner.peek() {
if b == target { return scanner.position(); }
scanner.advance();
}
- Zero warnings. Code must compile with
#[deny(warnings)] cleanly. Run cargo clippy and address all lints.
- Use
#[expect(lint, reason = "...")] instead of #[allow]. #[expect] warns when the suppression becomes unnecessary, preventing stale silencing. Always include a reason string. (M-LINT-OVERRIDE-EXPECT)
- Panics mean stop the program. Panics are not exceptions. Never use
panic!, unwrap(), or unreachable!() for recoverable errors — use Result. expect() is acceptable only for proven invariants with a descriptive message. (M-PANIC-IS-STOP)
unsafe only when required by 3rd-party libraries (e.g. PyO3 macros). No other reasons to write unsafe code.
Naming conventions
Sources: Rust API Guidelines C-CONV, C-GETTER; Microsoft M-CONCISE-NAMES
- Conversion prefixes follow ownership semantics:
as_ — cheap reference-to-reference (no allocation, no copy)
to_ — expensive conversion, may allocate (e.g. to_string())
into_ — consumes self, returns owned value
- No
get_ prefix on getters. Use fn name(&self) -> &str, not fn get_name().
- Implement
From<T>, never Into<T>. The blanket impl gives you Into for free.
- Concise type names. Avoid hollow suffixes:
Service, Manager, Factory, Handler, Processor. If the name needs a suffix, the type does too much.
- Named constants over magic literals. Every literal with domain meaning gets a
const with a doc comment. No bare numbers, bytes, or strings in logic.
if b == b'\\' { i += 2; }
// good
const ESCAPE_BYTE: u8 = b'\\';
if b == ESCAPE_BYTE { scanner.skip_escaped(); }
Error handling
Sources: Microsoft M-APP-ERROR, M-ERRORS-CANONICAL-STRUCTS
thiserror for library crates, anyhow/eyre for application crates. Libraries expose structured errors; apps just need context chains.
- Error messages: lowercase, no trailing punctuation. Matches
std convention for composable .context() chains.
- Use
? propagation everywhere. Avoid match on Result when ? + .map_err() suffices.
- Never
unwrap() in non-test code. Use expect("reason") only for proven invariants.
- Canonical error struct pattern:
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("invalid token at position {position}")]
InvalidToken { position: usize, token: char },
#[error("unexpected end of input")]
UnexpectedEof,
#[error(transparent)]
Io(#[from] std::io::Error),
}
Domain-driven design
Adapted from Domain-Driven Design (Evans/Fowler) for Rust.
Ubiquitous language
Name types, functions, and modules in the language of the problem domain, not the implementation.
fn find_end_offset(s: &str) -> Option<usize>
fn check_string(s: &str) -> bool
fn PaymentResult::validate(invoice: &Invoice) -> Option<PaymentResult>
fn ClassName::is_tailwind(token: &str) -> bool
Value objects as structs
Domain values without identity are structs. Functions take &Struct and return new structs — no mutation through output parameters.
fn process(input: &Config, out: &mut String)
fn process(input: &Config) -> ProcessResult
Enums for closed domain rules
When a domain has a fixed set of variants or checks, use an enum — not trait objects, not loose functions.
fn is_not_empty(s: &str) -> bool { ... }
fn starts_with_letter(s: &str) -> bool { ... }
enum ValidationRule { NonEmpty, StartsWithLetter, ContainsHyphen }
impl ValidationRule {
fn passes(self, input: &str) -> bool { match self { ... } }
}
const RULES: &[ValidationRule] = &[ValidationRule::NonEmpty, ...];
Traits for open abstractions
Use traits when behavior needs to be extended by future implementations. Start with the trait, then implement concrete types.
fn run_git_cmd() -> Output { ... }
fn run_uv_cmd() -> Output { ... }
trait ExternalCommand {
fn execute(&self) -> Result<Output>;
}
impl ExternalCommand for Git { ... }
impl ExternalCommand for Uv { ... }
See also Service & middleware and Trait object plugin in Ecosystem patterns.
Modules as bounded contexts
Each Rust module is a bounded context. Types and functions within a module share a domain model; the module boundary is the public API. Keep internal helpers private.
Sans-I/O: separate protocol logic from transport
Source: sans-io.readthedocs.io
Protocol logic (parsing, validation, state machines, data transformation) must be pure functions or types that take data in and return data out — no sockets, no channels, no async, no file handles. I/O operations (network, channels, disk) live in a thin outer layer that calls the protocol layer.
This makes protocol logic testable without standing up real infrastructure, reusable across different I/O backends (tokio, crossbeam, sync), and composable across boundaries (Rust ↔ Python).
impl SlotSend {
fn __call__(&self, py, event: &PyDict) -> PyResult<...> {
let type_val: String = event.get_item("type")?.extract()?;
match type_val.as_str() {
"http.response.start" => {
let status = event.get_item("status")?.extract()?;
*self.status.lock() = Some(status);
}
"http.response.body" => {
let body = event.get_item("body")?.extract()?;
self.outbound_tx.send(OutboundSlot { ... })?;
self.body_tx.send(body)?;
}
}
}
}
enum SendEvent {
Start { status: u16, headers: Vec<(Bytes, Bytes)> },
Body { data: Bytes, more_body: bool },
}
fn parse_send_event(event: &Bound<'_, PyDict>) -> PyResult<SendEvent> {
}
impl SlotSend {
fn __call__(&self, py, event: &PyDict) -> PyResult<...> {
let parsed = parse_send_event(event)?;
self.dispatch(parsed)
}
}
The same principle applies to request classification:
async fn handle(self, req: Request<Incoming>) -> Response<...> {
if req.uri().path() == "/_health/alive" {
return json_response(HEALTH_ALIVE);
}
if is_websocket_upgrade(&req) {
return self.dispatch.dispatch_ws(req).await;
}
}
enum RequestKind {
Probe(ProbeKind),
WebSocket,
Http,
}
fn classify(path: &str, headers: &HeaderMap) -> RequestKind {
}
async fn handle(self, req: Request<Incoming>) -> Response<...> {
match classify(req.uri().path(), req.headers()) {
RequestKind::Probe(kind) => probe_response(kind),
RequestKind::WebSocket => self.dispatch.dispatch_ws(req).await,
RequestKind::Http => self.dispatch_http(req).await,
}
}
Rule of thumb: if a function touches both data transformation AND a channel/socket/file, split it. The data transformation half is the protocol layer; the channel/socket half is the I/O layer. The protocol layer should be testable with #[test] using synthetic inputs — no #[tokio::test], no channels, no Python::attach.
API design
Sources: Microsoft M-INIT-BUILDER, M-IMPL-ASREF, M-IMPL-IO, M-AVOID-WRAPPERS; Rust API Guidelines C-COMMON-TRAITS
- Builder pattern for complex initialization. When a type has 4+ optional configuration fields, provide a builder instead of a constructor with many parameters.
- Accept
impl AsRef<str> / impl AsRef<Path> over concrete &str/String/&Path in function params when callers may have either type.
- Accept
impl Read / impl Write for I/O functions. Decouples logic from concrete I/O sources — enables testing with Cursor<Vec<u8>>.
- Avoid smart pointers in public APIs. Don't expose
Arc<Mutex<T>>, Box<T>, Rc<T> — let callers choose their wrapping strategy.
- Eagerly implement common traits:
Debug, Clone, PartialEq, Default on all public types. (C-COMMON-TRAITS)
- All public types must implement
Debug. No exceptions. (M-PUBLIC-DEBUG)
- Avoid unnecessary
Copy. Do not derive or implement Copy unless the type genuinely benefits from implicit copy semantics. Prefer Clone with explicit .clone() so copies are visible and intentional.
Code patterns
Return values, don't mutate
Functions return domain types instead of writing into &mut parameters. This makes data flow explicit and enables composition via .map(), .fold(), iterators.
fn transform(input: &str, out: &mut Vec<String>)
fn transform(input: &str) -> Vec<TransformResult>
Flat validation with early returns
Split complex validation into a scanning step and a checking step. Each is its own function. No nesting beyond one level.
if b == CLOSE {
if i + 1 < len && bytes[i + 1] == CLOSE {
if i == 0 { return None; }
return Some(i);
}
return None;
}
fn scan(input: &str) -> Option<Boundary> { ... }
fn validate(pos: usize, bytes: &[u8]) -> Option<Boundary> { ... }
Iterator chains over indexed loops
Prefer .iter(), .map(), .filter(), .collect() over for i in 0..len with manual indexing. Iterator chains are bounds-checked by construction.
Cow<'a, str> for conditional ownership
When a function sometimes borrows and sometimes allocates, return Cow<'a, str> instead of always cloning.
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains(' ') {
Cow::Owned(input.replace(' ', "_"))
} else {
Cow::Borrowed(input)
}
}
Pre-allocate when size is known
Use String::with_capacity() / Vec::with_capacity() when the final size is known or estimable. Avoids repeated reallocations.
Protocol functions are pure
If a function parses, validates, classifies, or transforms data — it must not touch I/O (channels, sockets, files, async runtimes). Accept data in, return data out. This makes protocol logic testable with #[test] (no #[tokio::test], no Python::attach, no channel setup).
async fn handle_response(data: ResponseData, tx: &mpsc::Sender<Bytes>) -> Result<()> {
let status = StatusCode::from_u16(data.status)?;
for chunk in data.chunks { tx.send(chunk).await?; }
Ok(())
}
fn build_response(data: ResponseData) -> Result<OutboundResponse> {
let status = StatusCode::from_u16(data.status)?;
Ok(OutboundResponse { status, headers: data.headers, body: data.body })
}
Test-first for bugs
When hitting a bug, write a failing test that reproduces it first. Only then write the fix. Tests document the exact failure mode and prevent regressions.
Ecosystem patterns
Production Rust relies on patterns popularized by Tokio, Axum, Tower, and Serde. These bridge the gap between the micro-level code patterns above and full application architecture.
Builder
Source: Rust API Guidelines C-BUILDER
Separate construction from representation. A builder accumulates configuration through method chaining and produces the final value in a terminal .build() call that can validate and fail.
Expands API design rule: "Builder pattern for complex initialization."
pub struct ServerConfig {
bind_addr: SocketAddr,
workers: usize,
tls: Option<TlsConfig>,
}
pub struct ServerConfigBuilder {
bind_addr: Option<SocketAddr>,
workers: usize,
tls: Option<TlsConfig>,
}
impl ServerConfigBuilder {
pub fn new() -> Self {
Self { bind_addr: None, workers: 1, tls: None }
}
pub fn bind_addr(mut self, addr: SocketAddr) -> Self {
self.bind_addr = Some(addr);
self
}
pub fn workers(mut self, n: usize) -> Self {
self.workers = n;
self
}
pub fn tls(mut self, config: TlsConfig) -> Self {
self.tls = Some(config);
self
}
pub fn build(self) -> Result<ServerConfig, ConfigError> {
let bind_addr = self.bind_addr.ok_or(ConfigError::MissingBindAddr)?;
Ok(ServerConfig { bind_addr, workers: self.workers, tls: self.tls })
}
}
Use when:
- A type has 4+ optional configuration fields
- Construction requires validation that can fail
- You want to guide callers through configuration step-by-step
Avoid when:
- A simple
new() with 1-3 required fields suffices
- The type is a plain data carrier with no invariants
Newtype
Source: Rust Design Patterns — Newtype
Wrap a primitive in a single-field tuple struct to enforce domain invariants at construction time. The inner value is private; access goes through validated constructors and accessor methods.
Complements Safety rule #9: "Domain types over raw primitives."
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct QualName(String);
#[derive(Debug, thiserror::Error)]
pub enum QualNameError {
#[error("qualified name must not be empty")]
Empty,
#[error("qualified name has empty segment: {0}")]
EmptySegment(String),
#[error("invalid qualified name segment: {0}")]
InvalidSegment(String),
}
impl QualName {
pub fn new(name: impl Into<String>) -> Result<Self, QualNameError> {
let name = name.into();
if name.is_empty() {
return Err(QualNameError::Empty);
}
for segment in name.split('.') {
if segment.is_empty() {
return Err(QualNameError::EmptySegment(name));
}
}
Ok(Self(name))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
Use when:
- A raw type (
String, u64, Vec<u8>) has domain constraints (non-empty, positive, valid format)
- You need to prevent mixing two semantically different values of the same underlying type
- The type appears in public APIs and you want compile-time safety
Avoid when:
- The wrapper adds no invariants and just obscures the inner type
- You only need it in one internal function (use a local
let binding instead)
Extension trait
Source: Rust API Guidelines C-EXT
Add methods to a foreign type (one you don't own) by defining a trait and implementing it for that type. Callers use the trait to get the new methods.
pub trait ToolResultExt {
fn from_serializable(value: &impl StructuredObject) -> Self;
fn from_serializable_error(value: &impl StructuredObject) -> Self;
}
impl ToolResultExt for CallToolResult {
fn from_serializable(value: &impl StructuredObject) -> Self {
build_structured_result(value, false)
}
fn from_serializable_error(value: &impl StructuredObject) -> Self {
build_structured_result(value, true)
}
}
Use when:
- You need to add domain-specific methods to a type from an external crate
- Multiple call sites would otherwise repeat the same conversion/construction logic
- The methods form a coherent semantic group (name the trait after the capability, e.g.
*Ext)
Avoid when:
- A free function would be equally clear and doesn't benefit from method syntax
- You own the type — just add methods directly
Type-state
Source: Cliffle — Rust Typestate Pattern
Encode protocol states as zero-sized type parameters. Methods that are only valid in a specific state are only available on that parameterization. State transitions consume the old value and return a new one.
pub struct Disconnected;
pub struct Connected;
pub struct Connection<S> {
addr: SocketAddr,
_state: std::marker::PhantomData<S>,
}
impl Connection<Disconnected> {
pub fn new(addr: SocketAddr) -> Self {
Self { addr, _state: PhantomData }
}
pub async fn connect(self) -> Result<Connection<Connected>, io::Error> {
Ok(Connection { addr: self.addr, _state: PhantomData })
}
}
impl Connection<Connected> {
pub async fn send(&self, data: &[u8]) -> Result<(), io::Error> {
Ok(())
}
}
Use when:
- An object has a clear lifecycle with distinct phases (disconnected → connected, unvalidated → validated)
- Calling methods out of order is a logic error you want to catch at compile time
- State transitions are linear and well-defined
Avoid when:
- States are dynamic or user-driven (use an enum instead)
- The number of states or transitions is large — type-state combinatorics explode quickly
Derive macro
Source: The Little Book of Rust Macros
Declarative (macro_rules!) or procedural macros that auto-derive trait implementations or generate boilerplate. Used sparingly, they eliminate repetitive patterns that generics and traits alone cannot.
Controlled exception to Safety rule #8: "Macros sparingly." Derive macros are acceptable when the pattern is mechanical, repeated across many types, and error-prone to write by hand.
macro_rules! tool_response {
(
$(#[$meta:meta])*
$vis:vis struct $name:ident {
$( $(#[$field_meta:meta])* $field_vis:vis $field:ident : $ty:ty ),* $(,)?
}
) => {
$(#[$meta])*
#[derive(Debug, serde::Serialize)]
$vis struct $name {
$( $(#[$field_meta])* $field_vis $field : $ty, )*
}
impl $crate::tools::StructuredObject for $name {}
};
}
Use when:
- The same trait implementation is mechanically identical across 5+ types
- Forgetting the impl is a common source of bugs (like the
StructuredObject marker above)
- The macro body is short and readable
Avoid when:
- Generics, blanket impls, or
#[derive(...)] from serde/thiserror already handle it
- The macro hides non-trivial control flow or business logic
- Only 1-2 types need the pattern (just write the impls by hand)
Service & middleware
Source: Tower — Service trait
A service is a trait with a single async call method that transforms a request into a response. Middleware wraps an inner service, adding cross-cutting behavior (logging, auth, timeouts) without modifying business logic. Services compose into layered stacks.
Architectural application of DDD "Traits for open abstractions." See also Trait object plugin.
pub trait HandlerDispatch: Send + Sync + std::fmt::Debug {
fn handle(
&self,
route: Arc<BoundRoute>,
app_state: Arc<AppState>,
request: InboundRequest,
) -> Pin<Box<dyn Future<Output = Result<OutboundResponse, AppError>> + Send>>;
}
pub struct RequestResponseDispatch;
impl HandlerDispatch for RequestResponseDispatch {
fn handle(
&self,
route: Arc<BoundRoute>,
app_state: Arc<AppState>,
mut request: InboundRequest,
) -> Pin<Box<dyn Future<Output = Result<OutboundResponse, AppError>> + Send>> {
Box::pin(async move {
let ctx = extract_context(&mut request, &route, &app_state).await?;
let result = invoke_handler(&route, &ctx).await?;
Python::attach(|py| serialize_result(py, &result, &route))
})
}
}
Use when:
- You have cross-cutting concerns (auth, logging, metrics, rate limiting) that apply to many handlers
- Multiple dispatch strategies share the same request/response contract
- You want to swap or layer behaviors without modifying core logic
Avoid when:
- There's only one handler and no middleware needed — a plain function is simpler
- The "service" would only ever have one implementation (use a concrete type)
Async trait + future abstraction
Source: Rust Reference — async fn in traits
Before Rust 1.75, async methods in traits required returning Pin<Box<dyn Future<...> + Send>> manually. Since 1.75, async fn works directly in traits, but boxed futures remain necessary when you need trait objects (dyn Trait).
See also Async & concurrency.
pub trait HandlerDispatch: Send + Sync {
fn handle(
&self,
route: Arc<BoundRoute>,
app_state: Arc<AppState>,
request: InboundRequest,
) -> Pin<Box<dyn Future<Output = Result<OutboundResponse, AppError>> + Send>>;
}
pub trait Processor: Send + Sync {
async fn process(&self, input: &[u8]) -> Result<Vec<u8>>;
}
Use when:
- The trait will be used as
dyn Trait (e.g. stored in a Vec<Box<dyn Trait>>) — boxed futures are required
- You need
Send bounds on the future for multi-threaded runtimes
Avoid when:
- The trait is only used with generics (
impl Trait / T: Trait) — use native async fn directly
- You can use the
async-trait crate and its overhead is acceptable
Trait object plugin
Source: Rust Design Patterns — Strategy
Store Arc<dyn Trait> in a registry to support open-ended extension at runtime. Each plugin implements a shared trait. The registry dispatches by name or type, enabling plugin-style architectures.
Architectural application of DDD "Traits for open abstractions."
pub trait Plugin: Send + Sync {
fn name(&self) -> &str;
fn init(&self) -> Result<()>;
fn handle(&self, input: &[u8]) -> Result<Vec<u8>>;
}
pub struct PluginRegistry {
plugins: Vec<Arc<dyn Plugin>>,
}
impl PluginRegistry {
pub fn register(&mut self, plugin: Arc<dyn Plugin>) {
self.plugins.push(plugin);
}
pub fn dispatch(&self, name: &str, input: &[u8]) -> Result<Vec<u8>> {
let plugin = self.plugins.iter()
.find(|p| p.name() == name)
.ok_or_else(|| anyhow!("unknown plugin: {name}"))?;
plugin.handle(input)
}
}
Use when:
- The set of implementations is open-ended and grows over time (plugins, tool handlers, codecs)
- Implementations are registered at runtime (e.g. from config or feature flags)
- You need to store heterogeneous implementations in one collection
Avoid when:
- The set of variants is closed and known at compile time — use an enum with
match
- Dynamic dispatch overhead matters in a hot loop — use generics or enum dispatch
RAII guard
Source: Rust Book — Drop trait
Implement Drop on a wrapper type to guarantee cleanup when the value goes out of scope — even on panics or early returns. The guard owns the resource and releases it deterministically.
impl Drop for AsgiBodyStream {
fn drop(&mut self) {
if let Some(task) = self.handler_task.take() {
task.abort();
}
}
}
pub struct TempDir {
path: PathBuf,
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
Use when:
- A resource must be released regardless of control flow (files, tasks, locks, temp dirs)
- Forgetting cleanup is a common source of leaks or bugs
- The resource has a clear owner with a well-defined lifetime
Avoid when:
- The cleanup is optional or caller-dependent — provide an explicit
.close() method instead
- Shared ownership (
Arc) makes the drop point unpredictable — consider explicit shutdown signals
Documentation
Sources: Microsoft M-CANONICAL-DOCS, M-FIRST-DOC-SENTENCE, M-MODULE-DOCS, M-DOC-INLINE
- Summary sentence: one line, max 15 words. First line of
/// is the summary — keep it tight.
- Canonical doc sections (in order, only when applicable):
# Examples, # Errors, # Panics, # Safety.
- Explain parameters in prose, not parameter tables. Prose reads better and scales to complex interactions.
- Module-level docs with
//! on every public module. Explain what the module provides and when to use it.
- Use
#[doc(inline)] on re-exports to surface docs at the re-export site, not buried in submodules.
Async & concurrency
Sources: Microsoft M-YIELD-POINTS; Cloudflare clippy::await_holding_lock
- Never block the async runtime. No
std::thread::sleep(), no blocking I/O, no heavy CPU work in async context. Use tokio::task::spawn_blocking() instead.
- Yield in long CPU-bound async loops. Insert
tokio::task::yield_now().await periodically (every ~10-100us of CPU work) to avoid starving other tasks. (M-YIELD-POINTS)
- No locks across
.await points. A MutexGuard held across an .await can deadlock or block the runtime. Scope the lock, copy data out, then await.
let data = lock.lock().expect("lock poisoned");
send(data.clone()).await;
let data = { lock.lock().expect("lock poisoned").clone() };
send(data).await;
- Use
tokio::select! for concurrent operations with cancellation semantics.
- Prefer
std::sync::OnceLock over lazy_static! or once_cell::sync::Lazy for one-time initialization.
- See also Async trait + future abstraction in Ecosystem patterns.
Python interop (PyO3)
Treat Python as an external runtime boundary, not a normal library. The bridge/ module is the canonical boundary between Rust domain logic and the Python interpreter — all PyO3 usage is contained there.
Architectural application of DDD "Modules as bounded contexts." The Python boundary is a bounded context with its own adapter layer.
Python boundary layer
Only boundary modules may import PyO3 types (PyObject, Py<PyAny>, Python<'py>). Everything outside the boundary uses Rust domain types. The data pipeline is:
InboundRequest → PythonAdapter → ASGI callable → PythonAdapter → OutboundResponse
Rust domain types (InboundRequest, OutboundResponse, AppState) flow through the system. PyO3 conversions happen at the boundary, not deep in business logic.
Rule: If a module outside bridge/ needs to touch Python, it belongs in the bridge or needs a new adapter.
Domain types for Python objects
Wrap Py<PyAny> and Py<PyString> in semantic newtypes when they cross function boundaries. Raw Py<PyAny> in a function signature says nothing about what the object is.
Complements Newtype pattern: same principle, applied to Python references.
fn call_app(app: &Py<PyAny>, scope: &Py<PyAny>) -> PyResult<Py<PyAny>>
struct AsgiApp(Py<PyAny>);
struct AsgiScope(Py<PyDict>);
struct AsgiReceive(Py<PyAny>);
fn call_app(app: &AsgiApp, scope: &AsgiScope) -> PyResult<AsgiReceive>
Group related interned Python objects into a struct rather than passing individual Py<PyString> values:
pub struct ScopeInterns {
pub type_key: Py<PyString>,
pub asgi_key: Py<PyString>,
pub http_version_key: Py<PyString>,
}
Zero allocation in the hot path
Core rule: Python objects must not be constructed in the hot request path. All Python objects must either be cached, pooled, or reused.
Three strategies:
- Intern — for strings and dict keys that are the same on every request. Use
pyo3::intern! or pre-build Py<PyString> at startup.
- Template + copy — for dicts with a fixed structure. Build a template dict once at startup, then
dict.copy() + mutate per-request fields. Cheaper than PyDict::new() + full population.
- Pool — for complex objects that are expensive to construct. Allocate a pool of objects, check out per-request, reset and return.
fn build_scope(py: Python<'_>, request: &InboundRequest) -> PyResult<Py<PyDict>> {
let scope = PyDict::new(py);
scope.set_item("type", "http")?;
scope.set_item("asgi", asgi_dict)?;
Ok(scope.into())
}
fn scope_from_template(
py: Python<'_>,
template: &Py<PyDict>,
request: &InboundRequest,
interns: &ScopeInterns,
) -> PyResult<Py<PyDict>> {
let scope = template.bind(py).call_method0(intern!(py, "copy"))?;
scope.set_item(&interns.path_key, &request.path)?;
Ok(scope.unbind())
}
Use Vec::with_capacity / pre-sized PyList + append over building collections from scratch.
Pre-resolve Python symbols
Cache Python attributes at startup in a struct, not via per-call getattr. Every getattr does a dict lookup on the Python side.
fn schedule(py: Python<'_>, loop_obj: &Py<PyAny>, callback: Py<PyAny>) -> PyResult<()> {
loop_obj.call_method1(py, "call_soon_threadsafe", (callback,))?;
Ok(())
}
pub struct EventLoopHandle {
call_soon_threadsafe: Py<PyAny>,
create_task: Py<PyAny>,
}
impl EventLoopHandle {
fn schedule(&self, py: Python<'_>, callback: Py<PyAny>) -> PyResult<()> {
self.call_soon_threadsafe.call1(py, (callback,))?;
Ok(())
}
}
Use pyo3::intern! for string keys used in dict operations (set_item, get_item). The macro caches the Python string across calls.
Minimize pyclass in hot paths
#[pyclass] creates Python heap objects — every instantiation goes through Python's allocator. In request-handling hot paths, prefer alternatives:
PyCFunction::new_closure for one-shot callables. Creates a Python callable from a Rust closure without a #[pyclass] heap allocation.
#[pyo3(freelist = N)] when a pyclass is unavoidable in the hot path. Maintains a free list of pre-allocated instances.
- Zero-overhead awaitables (e.g.
ResolvedAwaitable) over pyo3_async_runtimes::tokio::future_into_py when the result is already available synchronously.
#[pyclass]
struct Callback { inner: Box<dyn FnOnce()> }
let callback = PyCFunction::new_closure(py, None, None, move |args, _kwargs| {
Ok(())
})?;
See RAII guard for cleanup patterns on pyclass objects that own resources.
GIL discipline
The GIL is the single biggest contention point at the Python boundary. Rules:
- Keep GIL holds as short as possible. Acquire, enqueue work, release. Don't execute heavy logic under the GIL.
- Never hold GIL across
.await. This extends the async rule about locks — the GIL is a global lock.
- Defer heavy Python work to the event loop thread via
call_soon_threadsafe. Brief GIL on the tokio worker (to enqueue), heavy Python work on the event loop thread.
- Batch boundary crossings. Multiple sequential
Python::attach calls that could be one are wasted overhead.
Python::attach(|py| {
let result = expensive_python_call(py)?;
process_result(py, &result)?;
schedule_next(py, &result)?;
Ok(())
})
Python::attach(|py| {
let callback = prepare_callback(py, &data)?;
event_loop.schedule(py, callback)?;
Ok(())
})
One Python runtime per worker
Each worker owns its own: interpreter, event loop, symbol cache, and object pools. Nothing crosses worker boundaries — there is no GIL contention between workers.
Worker-scoped Python state lives in AppState. This includes interned strings, scope templates, cached callables, and connection pools. Initialize all of it once during worker startup.
Rule: Never pass Py<T> between workers. If data must cross workers, serialize to Rust types first.
Treat Python as an RPC boundary
Mental model: Rust sends a request to the Python runtime and receives a response. It does not "call Python functions" — it dispatches work across a boundary.
Consequences:
- Batch work. Prepare all data on the Rust side, make one boundary crossing, collect all results.
- Reuse objects. Templates, interns, and pools amortize the cost of boundary crossings over many requests.
- Minimize crossings. Each
Python::attach / call_method is an RPC call with overhead. Coalesce where possible.
Anti-pattern: Multiple sequential Python::attach calls that could be a single attach block:
Python::attach(|py| build_scope(py, &req))?;
Python::attach(|py| call_app(py, &scope))?;
Python::attach(|py| read_response(py, &result))?;
Python::attach(|py| {
let scope = build_scope(py, &req)?;
let result = call_app(py, &scope)?;
read_response(py, &result)
})
Lints & static analysis
Sources: Microsoft M-STATIC-VERIFICATION; Cloudflare foundations
- Recommended
Cargo.toml lint config:
[lints.rust]
unreachable_pub = "warn"
[lints.clippy]
unwrap_used = "warn"
clone_on_ref_ptr = "warn"
await_holding_lock = "deny"
large_futures = "warn"
#[expect] over #[allow], always with reason. #[expect] warns when the suppression is no longer needed — prevents lint rot.
- Run
cargo clippy -- -D warnings as the zero-warnings gate. No code merges with clippy warnings.
Testing
- Descriptive test names. Use
test_<unit>_<scenario>_<expected> format: test_parse_empty_input_returns_error.
- Use
tempfile for filesystem tests. Never write to hardcoded paths or the working directory.
- Assert
Send/Sync at compile time for types that cross thread boundaries:
fn _assert_send<T: Send>() {}
fn _assert_sync<T: Sync>() {}
#[test]
fn connection_pool_is_send_sync() {
_assert_send::<ConnectionPool>();
_assert_sync::<ConnectionPool>();
}
- Test error
Display output and verify .source() chains — error messages are part of the public API.
- Use
#[should_panic(expected = "...")] for panic tests — always include the expected substring.