Rust’s ownership system, borrow checker, and lifetime rules have a steep learning curve — not because the concepts are arbitrary but because they’re unfamiliar to developers coming from garbage-collected languages. Claude Code significantly reduces Rust’s friction because it generates code that satisfies the borrow checker and writes idiomatic patterns for the situations that trip people up: shared mutable state, async lifetimes, error propagation.
This guide covers Rust development with Claude Code: ownership patterns, error handling, async programming with Tokio, CLI development, and performance-critical code.
Setting Up Claude Code for Rust Projects
Rust projects have meaningful toolchain and pattern choices that affect everything:
# Rust Project Context
## Stack
- Rust 1.77 (stable), Cargo workspace
- Async: Tokio 1.0 with multi-thread runtime
- HTTP: Axum 0.7
- Serialization: serde with derive macros
- Error handling: thiserror for libraries, anyhow for binaries
- CLI: clap 4 with derive API
- Database: sqlx 0.7 async (PostgreSQL)
## Conventions
- Errors: use ? operator — don't unwrap() in library code
- Clone only when necessary — prefer references and lifetimes
- derive: always #[derive(Debug)] on all types
- Logging: tracing crate (not log)
- Tests: unit tests in the same file (mod tests), integration tests in tests/
## Never
- unwrap() or expect() in production code paths
- unsafe unless absolutely necessary — explain why
- Large types on the stack — box or Rc/Arc for heap allocation
See the CLAUDE.md setup guide for full setup.
Ownership and Borrowing
Claude Code generates code that satisfies the borrow checker because it understands Rust’s ownership rules — not just the syntax:
Sharing Data
I need to pass a Config struct to multiple functions that run concurrently.
Config is read-only after construction. How do I share it?
use std::sync::Arc;
use tokio::task;
#[derive(Debug)]
struct Config {
database_url: String,
api_key: String,
max_connections: u32,
}
#[tokio::main]
async fn main() {
let config = Arc::new(Config {
database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL required"),
api_key: std::env::var("API_KEY").expect("API_KEY required"),
max_connections: 10,
});
// Clone the Arc (cheap — increments ref count, not the data)
let config_for_task1 = Arc::clone(&config);
let config_for_task2 = Arc::clone(&config);
let task1 = task::spawn(async move {
run_service_a(&config_for_task1).await
});
let task2 = task::spawn(async move {
run_service_b(&config_for_task2).await
});
tokio::try_join!(task1, task2).unwrap();
}
Arc (Atomic Reference Counting) for shared read-only data across threads. Claude explains: Rc is for single-threaded use, Arc for multi-threaded. Arc::clone is cheap — it copies the pointer, not the data.
Shared Mutable State
I need a cache that multiple async tasks can read and write.
What's the right pattern?
use std::sync::Arc;
use tokio::sync::RwLock;
use std::collections::HashMap;
type Cache = Arc<RwLock<HashMap<String, String>>>;
async fn get_cached(cache: &Cache, key: &str) -> Option<String> {
let reader = cache.read().await;
reader.get(key).cloned()
}
async fn set_cached(cache: &Cache, key: String, value: String) {
let mut writer = cache.write().await;
writer.insert(key, value);
}
tokio::sync::RwLock (not std::sync::RwLock) for async code — the std version blocks the thread, which stalls the Tokio runtime. Claude knows this distinction and always uses the async-aware synchronization primitives.
Lifetimes
Write a function that returns the longest of two string slices.
Explain why the lifetime annotation is needed.
// Without this annotation, the compiler doesn't know if the returned
// reference lives as long as 'a or 'b
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
if s1.len() >= s2.len() { s1 } else { s2 }
}
Claude explains: the lifetime 'a tells the compiler “the returned reference lives as long as the shorter of s1 and s2”. Without it, the compiler can’t guarantee the reference outlives both inputs. For complex lifetime scenarios common in async code, Claude generates the correct annotations and explains the constraint.
Error Handling
thiserror for Libraries
Create an error type for a file processing library.
Errors: IO error, invalid format, missing required field, size limit exceeded.
Should work with ? operator and provide helpful messages.
use thiserror::Error;
use std::path::PathBuf;
#[derive(Debug, Error)]
pub enum ProcessingError {
#[error("Failed to read file {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("Invalid file format at line {line}: {message}")]
InvalidFormat { line: u32, message: String },
#[error("Required field '{field}' is missing")]
MissingField { field: &'static str },
#[error("File size {actual_bytes} exceeds limit of {limit_bytes} bytes")]
SizeExceeded { actual_bytes: u64, limit_bytes: u64 },
}
// Use in library code:
fn process_file(path: &Path) -> Result<ProcessedData, ProcessingError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ProcessingError::Io { path: path.to_owned(), source: e })?;
parse_content(&content)
}
thiserror derives Error and Display from the #[error("...")] messages. The #[source] attribute exposes the underlying error for std::error::Error::source().
anyhow for Binaries
I'm writing a CLI tool. I want to use ? everywhere
without defining custom error types.
use anyhow::{Context, Result};
fn run() -> Result<()> {
let config_path = std::env::var("CONFIG_PATH")
.context("CONFIG_PATH environment variable not set")?;
let config: Config = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config from {config_path}"))?
.parse()
.context("Invalid config format")?;
process(config)?;
Ok(())
}
anyhow::Result<T> is Result<T, anyhow::Error>. .context("message") adds context to any error, building a chain that prints as: “Invalid config format\n\nCaused by:\nexpected field ‘api_key’”. The .with_context(|| ...) closure avoids allocating the message string unless an error actually occurs.
Async Programming with Tokio
HTTP Client
Make async HTTP requests to multiple APIs concurrently.
Retry on 5xx errors up to 3 times with exponential backoff.
use reqwest::Client;
use tokio::time::{sleep, Duration};
async fn fetch_with_retry(client: &Client, url: &str, max_retries: u32) -> Result<String, reqwest::Error> {
let mut attempt = 0;
loop {
match client.get(url).send().await {
Ok(response) if response.status().is_server_error() => {
attempt += 1;
if attempt >= max_retries {
return Ok(response.text().await?);
}
let backoff = Duration::from_millis(100 * 2u64.pow(attempt));
sleep(backoff).await;
}
Ok(response) => return Ok(response.text().await?),
Err(e) if attempt < max_retries => {
attempt += 1;
sleep(Duration::from_millis(100 * 2u64.pow(attempt))).await;
}
Err(e) => return Err(e),
}
}
}
// Concurrent requests with tokio::join!
async fn fetch_all(client: &Client) -> Result<(String, String, String), reqwest::Error> {
tokio::try_join!(
fetch_with_retry(client, "https://api1.example.com/data", 3),
fetch_with_retry(client, "https://api2.example.com/data", 3),
fetch_with_retry(client, "https://api3.example.com/data", 3),
)
}
Axum HTTP Server
Build a REST API with Axum.
Routes: GET /users, POST /users, GET /users/:id
JWT authentication middleware.
use axum::{
Router,
routing::{get, post},
extract::{Path, State, Json},
middleware,
http::StatusCode,
};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db: sqlx::PgPool,
config: Arc<Config>,
}
#[tokio::main]
async fn main() {
let pool = sqlx::PgPool::connect(&std::env::var("DATABASE_URL").unwrap())
.await
.expect("Failed to connect to database");
let state = AppState { db: pool, config: Arc::new(Config::from_env()) };
let protected = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user))
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
let app = Router::new()
.merge(protected)
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn get_user(
Path(id): Path<i64>,
State(state): State<AppState>,
) -> Result<Json<User>, StatusCode> {
sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
Axum’s extractor system (Path, State, Json) and routing API are specifically designed for Tokio’s async model. Claude generates idiomatic Axum code — handler functions that return Result<impl IntoResponse, StatusCode>.
CLI Tools with Clap
Build a CLI with clap derive API.
Subcommands: process (with --input, --output, --verbose),
validate (with --schema), and report (with --format json|text).
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "myapp", version, about = "Data processing tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Process {
#[arg(short, long)]
input: PathBuf,
#[arg(short, long, default_value = "./output")]
output: PathBuf,
#[arg(short, long)]
verbose: bool,
},
Validate {
#[arg(long)]
schema: PathBuf,
},
Report {
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
}
#[derive(ValueEnum, Debug, Clone)]
enum OutputFormat { Json, Text }
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Process { input, output, verbose } => {
run_process(input, output, verbose);
}
Commands::Validate { schema } => {
run_validate(schema);
}
Commands::Report { format } => {
run_report(format);
}
}
}
Clap’s derive API generates argument parsing from struct annotations. Claude uses it correctly — #[command] for the root, #[arg] for arguments, ValueEnum for constrained choices.
Performance and Unsafe
I have a hot path that processes byte buffers.
The profiler shows the bottleneck is bounds checking.
How do I use unsafe to eliminate it, correctly?
Claude writes the unsafe code with documentation explaining:
- What invariant makes this safe (buffer length checked before the loop)
- Why the bounds check is safe to skip (index derived from length, can’t exceed bounds)
- Adds a
debug_assert!that runs in debug builds to catch violations
It treats unsafe seriously — explaining the safety argument in comments, keeping the unsafe block as small as possible, and checking whether a safe alternative exists first.
Rust with Claude Code: Learning the Borrow Checker
The most common Rust friction is the borrow checker rejecting code that feels correct. The pattern that works with Claude Code:
This code doesn't compile:
[paste code]
Compiler error: [paste error message]
Claude reads the error, identifies the ownership issue (moved value used after move, mutable and immutable borrow coexist, lifetime doesn’t live long enough), and proposes the minimal fix — not a rewrite. It explains why the fix works in terms of the ownership model rather than just making the error go away.
For comprehensive Rust patterns — custom allocators, async traits, the pin/unpin system, procedural macros — the Claude Skills 360 bundle includes Rust skill sets covering systems programming, WebAssembly, and embedded targets. See the testing guide for Rust testing patterns including property-based testing with proptest. Start with the free tier to try the error handling and ownership patterns.