Rust Development Patterns
You ask the AI to “refactor this function” and it hands back code that sprinkles .clone() on every line, invents a tokio API that does not exist, or writes a query_as! macro against a struct whose fields do not match the columns you SELECT. The borrow checker rejects it, the compiler rejects it, and now you are reverse-engineering AI output instead of shipping. Rust punishes vague prompting harder than any other language: there is exactly one ownership story the compiler will accept, and “make it work” is not it.
The fix is to prompt with the constraint, not the wish. Tell the agent who keeps ownership, which error variant maps to which HTTP status, and which table the query touches — and let it verify against the real compiler and the live schema.
What You’ll Walk Away With
Section titled “What You’ll Walk Away With”- A prompting pattern that gets the agent to fix borrow-checker errors by changing signatures, not by cloning
- A consistent
User/CreateUser/AppErrormodel you can drop into an Axum + SQLx service - A maintained Postgres MCP server wired up so the agent reads your real schema before writing compile-checked
query_as! - Three or more copy-paste prompts for ownership, error propagation, async shutdown, and zero-copy parsing
- A “When This Breaks” checklist for the failure modes unique to AI-generated Rust
The Setup: One Stack, Stated Once
Section titled “The Setup: One Stack, Stated Once”Pick one stack and put it in your rules file so the agent stops mixing Actix, Diesel, and Rocket into the same project. This recipe standardizes on Axum + SQLx + Tokio throughout. SQLx (not an ORM) is the right pairing here because its query_as! macro checks every query against your database at compile time — which is exactly the kind of guardrail that catches AI hallucinations before they ship.
Add a .cursor/rules/rust.mdc file so every agent turn inherits the constraints:
---description: Rust backend conventionsalwaysApply: true---- Rust 2021 edition, stable toolchain- Stack: Axum + SQLx (Postgres) + Tokio. Do NOT introduce Actix, Diesel, or Rocket.- Return Result<T, AppError>; never .unwrap()/.expect() outside tests- Use thiserror for the error enum, the `?` operator to propagate- Prefer borrowing (&str, &[T]) over taking ownership unless the callee must store the value- Every SQLx call uses query_as!/query! macros (compile-time checked), never runtime query()Put the same constraints in CLAUDE.md at the repo root so they load into every session:
## Rust conventions- Rust 2021, stable. Stack: Axum + SQLx (Postgres) + Tokio only.- Result<T, AppError> everywhere; no .unwrap() outside #[cfg(test)].- thiserror enum + `?` propagation. Borrow by default; take ownership only to store.- SQLx: query_as!/query! macros (compile-checked). Run `cargo sqlx prepare` after schema changes.Then let Claude Code scaffold the workspace in the terminal:
claude "Scaffold a Cargo workspace: an `api` crate (Axum) and a `db` crate (SQLx repositories). Add tokio, serde, thiserror, anyhow, tracing, sqlx with the postgres+macros+runtime-tokio features. Use caret version pins, not frozen minors."Codex reads AGENTS.md. Drop the same stack rules there, then drive it from the CLI:
codex "Scaffold a Cargo workspace with an Axum `api` crate and a SQLx `db` crate. Add tokio, serde, thiserror, anyhow, tracing, and sqlx (postgres, macros, runtime-tokio). Use caret pins."Codex defaults to its workspace-write sandbox, so it can run cargo check itself between edits. Add --ask-for-approval on-failure if you want it to pause only when a command errors.
The resulting Cargo.toml should use caret pins so it does not rot in six months:
[workspace]members = ["api", "db"]
[workspace.dependencies]tokio = { version = "1", features = ["full"] }serde = { version = "1", features = ["derive"] }sqlx = { version = "0.9", features = ["postgres", "macros", "runtime-tokio"] }thiserror = "2" # 2.x keeps the #[error(...)] derive syntax; major bump from 1.xanyhow = "1"tracing = "0.1"The Domain Model: Define It Once
Section titled “The Domain Model: Define It Once”Most AI-generated Rust falls apart because the model drifts — CreateUser has {email, name} in one block and {email, name, password} three blocks later, and the query_as! macro fails to compile against a User that is missing password_hash. Lock the types down first and tell the agent these are the only definitions allowed.
use chrono::{DateTime, Utc};
// The full row, matching the `users` table exactly.#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]pub struct User { pub id: i64, pub email: String, pub name: String, #[serde(skip_serializing)] pub password_hash: String, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,}
// Input for creation. Carries the plaintext password; the repository hashes it.#[derive(Debug, serde::Deserialize)]pub struct CreateUser { pub email: String, pub name: String, pub password: String,}Winning the Borrow-Checker Fight
Section titled “Winning the Borrow-Checker Fight”The single most useful Rust prompt is one that forbids the lazy escape hatch. When the agent hits an ownership error, its default is to .clone() the problem away or wrap everything in Arc. Tell it the ownership story you want instead.
Select the offending function, open the inline edit (Cmd/Ctrl+K), and paste a constraint-first prompt rather than “fix the error”:
// Before: takes ownership it doesn't need, so the caller loses `data`.fn process_data(data: Vec<String>) -> Vec<String> { data.into_iter().filter(|s| s.len() > 5).collect()}
// After the prompt below: borrows, so the caller keeps ownership.fn process_data(data: &[String]) -> Vec<String> { data.iter().filter(|s| s.len() > 5).cloned().collect()}Point Claude Code at the file and state the ownership invariant explicitly:
claude "In src/handlers.rs, process_data takes Vec<String> but the caller needs the original afterward. Change it to take &[String] so ownership stays with the caller. Update every call site and explain the one borrow each now needs."Codex can compile-check the change in its sandbox before returning it:
codex "process_data in src/handlers.rs should borrow &[String] instead of consuming Vec<String>, so the caller keeps the value. Update call sites and run `cargo check` to confirm it builds."Error Handling That Maps to HTTP
Section titled “Error Handling That Maps to HTTP”Define the error enum once with thiserror, then implement IntoResponse so Axum turns each variant into the right status code. The trap to avoid: an agent that catches sqlx::Error::RowNotFound and returns a 500 instead of a 404.
use thiserror::Error;use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};
#[derive(Error, Debug)]pub enum AppError { #[error("not found: {0}")] NotFound(String), #[error("validation failed: {0}")] Validation(String), #[error("unauthorized")] Unauthorized, #[error(transparent)] Database(#[from] sqlx::Error),}
pub type Result<T> = std::result::Result<T, AppError>;
impl IntoResponse for AppError { fn into_response(self) -> Response { let status = match self { // RowNotFound is a 404, not a 500 — the bug AI loves to write. AppError::Database(sqlx::Error::RowNotFound) | AppError::NotFound(_) => { StatusCode::NOT_FOUND } AppError::Validation(_) => StatusCode::BAD_REQUEST, AppError::Unauthorized => StatusCode::UNAUTHORIZED, AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, }; (status, Json(serde_json::json!({ "error": self.to_string() }))).into_response() }}The SQLx Repository, Backed by a Postgres MCP Server
Section titled “The SQLx Repository, Backed by a Postgres MCP Server”Here is the workflow change that matters most. query_as! is checked at compile time against your live database — so if the agent guesses a column name, the build fails. But the agent guesses precisely because it cannot see your schema. A Postgres MCP server closes that gap: it lets the agent introspect the real tables before writing a single macro, so the columns in the SELECT match the struct on the first try.
Use a maintained server. Anthropic’s old reference @modelcontextprotocol/server-postgres was deprecated and archived in July 2025 after a disclosed SQL-injection flaw — skip it. This recipe uses Postgres MCP Pro (crystaldba/postgres-mcp), which is actively maintained and ships a real read-only restricted mode. It runs via uvx or Docker (Python 3.12+ or Docker required) and takes the connection string in the DATABASE_URI environment variable.
All three tools speak MCP. Only the config file location differs (Cursor: .cursor/mcp.json; Claude Code: .mcp.json; Codex: ~/.codex/config.toml).
.cursor/mcp.json:
{ "mcpServers": { "postgres": { "command": "uvx", "args": ["postgres-mcp", "--access-mode=restricted"], "env": { "DATABASE_URI": "postgresql://localhost/myapp" } } }}claude mcp add --transport stdio --env DATABASE_URI=postgresql://localhost/myapp postgres \ -- uvx postgres-mcp --access-mode=restrictedThis writes the server into .mcp.json. Confirm with claude mcp list.
~/.codex/config.toml:
[mcp_servers.postgres]command = "uvx"args = ["postgres-mcp", "--access-mode=restricted"]env = { DATABASE_URI = "postgresql://localhost/myapp" }With the server connected, the repository the agent produces matches the User struct field-for-field:
use sqlx::PgPool;
pub struct UserRepository { pool: PgPool,}
impl UserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } }
pub async fn find_by_email(&self, email: &str) -> Result<Option<User>> { let user = sqlx::query_as!( User, "SELECT id, email, name, password_hash, created_at, updated_at \ FROM users WHERE email = $1", email ) .fetch_optional(&self.pool) .await?; Ok(user) }
pub async fn create(&self, input: &CreateUser) -> Result<User> { let password_hash = hash_password(&input.password)?; let user = sqlx::query_as!( User, "INSERT INTO users (email, name, password_hash) \ VALUES ($1, $2, $3) \ RETURNING id, email, name, password_hash, created_at, updated_at", input.email, input.name, password_hash ) .fetch_one(&self.pool) .await?; Ok(user) }}Async Services That Shut Down Cleanly
Section titled “Async Services That Shut Down Cleanly”A Tokio service that never exits cleanly leaks tasks and hangs your integration tests. The pattern that works: a CancellationToken from tokio-util plus a TaskTracker so shutdown waits for in-flight work.
use tokio_util::{sync::CancellationToken, task::TaskTracker};
pub async fn run(token: CancellationToken) { let tracker = TaskTracker::new(); loop { tokio::select! { // Cancelled — stop accepting work and drop out of the loop. _ = token.cancelled() => break, // Otherwise pull the next job and track it so shutdown can await it. job = next_job() => { let child = token.clone(); tracker.spawn(async move { tokio::select! { _ = handle(job) => {} _ = child.cancelled() => { /* abort mid-flight */ } } }); } } } tracker.close(); tracker.wait().await; // block until every spawned task has finished}Genuinely Zero-Copy Parsing
Section titled “Genuinely Zero-Copy Parsing”“Optimize for zero-copy” is a prompt that frequently produces the opposite: an owned Vec<u8> that gets re-indexed on every read, labeled zero-copy. Real zero-copy in Rust means Bytes/BytesMut with split_to, which hands out reference-counted slices that share the same allocation.
use bytes::BytesMut;use tokio::io::{AsyncRead, AsyncReadExt};
pub async fn frames<R: AsyncRead + Unpin>(mut reader: R) -> Result<usize> { let mut buf = BytesMut::with_capacity(8 * 1024); let mut total = 0; loop { // read_buf appends at the buffer's current length and grows as needed. if reader.read_buf(&mut buf).await? == 0 { break; } // split_to hands out an owned-but-shared slice — no byte copy. while buf.len() >= 4 { let frame = buf.split_to(4); total += frame.len(); // ... handle `frame` (a `BytesMut` view into the same allocation) } } Ok(total)}When This Breaks
Section titled “When This Breaks”- The agent clones everything. If output is littered with
.clone()andArc::new, it gave up on the borrow checker. Re-prompt with the ownership invariant (“the caller must keep this value”) and forbid clone explicitly. - Hallucinated crate versions or APIs. AI training data lags crates.io, so it pins old minors (
tokio = "1.35") or calls methods that were renamed. Use caret pins ("1","2"), and runcargo check— or let Codex/Claude Code run it — before trusting any new dependency. Verify a crate’s real latest withcargo search <crate>. query_as!won’t compile. The struct fields and theSELECTlist disagree, orDATABASE_URLis unset so SQLx can’t reach the schema. Connect the Postgres MCP server, runcargo sqlx prepareto cache query metadata, and make sure the struct’s fields exactly match the columns returned.thiserrorderive errors after upgrading to 2.x. The#[error("...")]syntax is unchanged, but a few attribute edge cases moved. Let the agent read the thiserror 2.0 changelog rather than guessing.- Mocking async traits fails.
#[mockall::automock]on a trait withasync fnneeds#[async_trait]or mockall’s async handling. When that fights you, a hand-written fake struct is simpler than wrestling the macro — tell the agent to write the fake instead.