Rust Development Patterns
Rust patterns for AI coding agents are rules, prompts, and gates that make Claude Code, Codex, and Cursor produce Axum, SQLx, and Tokio services the compiler accepts without .clone() spam: explicit ownership, one thiserror error type mapped to HTTP, query_as! checked against the real schema, and clean shutdown. Output is accepted on clippy, test, SQLx, and mutation-testing evidence.
You ask the agent for a user endpoint. It returns code that clones every String to quiet the borrow checker, registers the route as /users/:id (which panics at startup on axum 0.8), and writes a query_as! whose SELECT list does not match the struct. CI then fails because DATABASE_URL is not set there. Each fix takes another round, and you end up reading every line anyway.
This recipe is for developers who build Rust backends with an agent. It puts the constraints the compiler cares about into the prompts, and makes the toolchain, not your eyes, prove the result.
What an agent-proof Rust setup gives you
Section titled “What an agent-proof Rust setup gives you”- One
AGENTS.mdrule block for Rust that Claude Code, Codex, and Cursor all follow, plus amake checkgate the agent must pass before it reports “done” - Six copy-paste prompts: ownership refactors, a canonical domain model, a schema-aware SQLx repository, an error type mapped to HTTP, graceful shutdown, and database plus property tests
- A read-only Postgres MCP setup so the agent reads your schema before it writes a query
- A verification table that tells you which gate catches which bug, so you read gate output instead of the diff
- Recovery steps for the seven ways agent-written Rust breaks
Set up Rust conventions and a gate the agent must pass
Section titled “Set up Rust conventions and a gate the agent must pass”Write three files once per repository, before you ask for any code: a toolchain pin, a workspace manifest with current crate versions, and a rules file with a single definition of done.
-
Pin the toolchain in
rust-toolchain.toml. On 2026-09-26 the current stable release is Rust 1.98.1 (per thestatic.rust-lang.orgstable channel manifest). A pin stops the agent from assuming an older compiler, and it matters for SQLx:sqlx0.9.0 declaresrust-version = 1.94on crates.io.[toolchain]channel = "1.98.1"components = ["rustfmt", "clippy", "rust-analyzer"] -
Declare the stack once in the workspace
Cargo.toml. The versions are the latest on crates.io on 2026-09-26. Caret requirements letcargo updatemove patch releases; for a0.xcrate such as"0.9", the caret stays inside0.9.*.[workspace]members = ["api", "db"]resolver = "3"[workspace.package]edition = "2024"[workspace.dependencies]axum = "0.8"tokio = { version = "1", features = ["full"] }tokio-util = { version = "0.7", features = ["rt"] } # "rt" enables TaskTrackerserde = { version = "1", features = ["derive"] }serde_json = "1"sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "migrate", "chrono",] }chrono = { version = "0.4", features = ["serde"] }thiserror = "2"anyhow = "1"tracing = "0.1"argon2 = "0.6"proptest = "1"Two features are easy to miss. Without
chronoonsqlx, aDateTime<Utc>field does not compile inquery_as!. Withoutrtontokio-util,TaskTrackerdoes not exist. -
Add the gate as a
make checktarget. It needsDATABASE_URLpointing at a local development Postgres, because bothcargo sqlx prepare --checkand#[sqlx::test]connect to a server..PHONY: checkcheck:cargo fmt --all -- --checkcargo clippy --workspace --all-targets -- -D warningscargo sqlx prepare --workspace --checkcargo nextest run --workspacecargo test --workspace --doccargo deny checkInstall the tools once in your terminal, then generate a starting
deny.toml:Terminal window cargo install --locked cargo-nextest cargo-deny sqlx-cli cargo-mutantscargo deny initcargo nextestdoes not run doctests, which is why the gate runscargo test --docseparately.cargo deny checkcovers RustSec advisories, licences, and banned or duplicate crates in one command. -
Put the Rust rules in
AGENTS.mdat the repository root. Codex reads it natively; the tabs below show how Claude Code and Cursor pick up the same text.## Rust conventions- Edition 2024, toolchain from rust-toolchain.toml. Stack: axum 0.8 + sqlx 0.9(Postgres) + tokio. Do not add actix-web, diesel, rocket, or sea-orm.- axum 0.8 routes use `/{id}`, never `/:id`. Extractors implementFromRequestParts without #[async_trait].- Borrow by default (&str, &[T]). Take ownership only when the callee stores thevalue. Never add .clone(), Arc, or Rc just to satisfy the borrow checker;change the signature and explain why instead.- Every fallible function returns Result<T, AppError>. No .unwrap() or .expect()outside #[cfg(test)] and main().- SQL uses sqlx::query_as! / query! macros only. After any query or migrationchange, run `cargo sqlx prepare --workspace` and commit .sqlx/.- CPU-heavy or blocking work (password hashing, std::fs) runs intokio::task::spawn_blocking.- Never delete or loosen an existing test, and never add #[ignore], to reachgreen. If a test looks wrong, stop and say why.- Done means `make check` passes. Paste its last 20 lines in your final message.
The last two rules carry the most weight. The first stops the agent from weakening your tests to reach green; see protect the oracle for why that is the most common way agent work fakes success. The second turns “it compiles” into evidence you can read in ten seconds.
Wire the rules and gate into each tool
Section titled “Wire the rules and gate into each tool”The rules file and the prompts are identical across tools. What differs is how each tool loads the rules, formats code, and reviews the result.
Create a CLAUDE.md that imports the shared file. The @path import expands it into context at launch:
@AGENTS.mdFrom v2.1.277 (the latest channel on 2026-09-26), Claude Code also reads AGENTS.md on its own when a project has no CLAUDE.md. The import works on every channel.
Format every Rust file the agent edits with a PostToolUse hook in .claude/settings.json. Pass --edition 2024, because rustfmt run on a single file does not read the edition from Cargo.toml:
{ "hooks": { "PostToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "jq -r '.tool_input.file_path' | grep '\\.rs$' | xargs -r rustfmt --edition 2024" } ] } ] }}Give the agent type-aware navigation and compiler diagnostics with the rust-analyzer-lsp plugin from Anthropic’s official marketplace. The plugin registers the server only; the rust-analyzer component from step 1 provides the binary:
claude plugin install rust-analyzer-lsp@claude-plugins-officialBefore you open a pull request, run /code-review in the session for a second opinion on the diff.
Codex reads AGENTS.md directly, so no extra file is needed. Run a task with workspace writes and approval prompts for anything wider:
codex -s workspace-write -a on-request "Implement UserRepository as described in docs/tasks/users.md. Done means make check passes."If cargo cannot download crates from inside the sandbox, run cargo fetch in your own terminal first so the agent builds from the local registry cache.
Review the branch before you push it:
codex review --base mainCreate a project rule in Cursor’s Rules settings with the same list as AGENTS.md and attach it to Rust files. Install the rust-analyzer extension so the editor reports compiler diagnostics.
Use Plan Mode for the multi-crate scaffolds on this page so you approve the file list before any code is written. Run independent tasks in Worktrees, and let Bugbot review the pull request.
Start each tool on its default model and raise the effort setting before you switch models; the models hub lists the current defaults.
Build an Axum and SQLx service with an agent, step by step
Section titled “Build an Axum and SQLx service with an agent, step by step”Each step is one prompt, an excerpt of what a good answer looks like, and the gate that proves it. Run them in order: later prompts assume the model and repository exist.
How do you stop the agent from cloning its way past the borrow checker?
Section titled “How do you stop the agent from cloning its way past the borrow checker?”When an agent hits an ownership error, its default fix is .clone() or Arc::new. Both compile, and both hide the design question the error was asking. Name the ownership story you want and forbid the escape hatch.
A good answer changes the signature, not the call sites’ data:
// Before: consumes the vector, so callers clone it first.pub fn active_emails(users: Vec<User>) -> Vec<String> { users.into_iter().filter(|u| u.active).map(|u| u.email).collect()}
// After: borrows; the returned &str values live as long as `users`.pub fn active_emails(users: &[User]) -> Vec<&str> { users.iter().filter(|u| u.active).map(|u| u.email.as_str()).collect()}You do not need to audit lifetimes by eye: if the borrow is wrong, the build fails. What the compiler cannot tell you is whether the agent smuggled a clone back in elsewhere, so count them in the diff (see the verification section).
How do you pin the domain model so query_as! compiles?
Section titled “How do you pin the domain model so query_as! compiles?”Agent-written Rust often drifts: CreateUser has three fields in one file and two in another, and a query_as! fails because the SELECT list omits a column the struct declares. Define the types once and tell the agent they are the only definitions allowed.
use chrono::{DateTime, Utc};
/// One row of the `users` table, field for field.#[derive(Debug, Clone, serde::Serialize)]pub struct User { pub id: i64, pub email: String, pub name: String, pub active: bool, #[serde(skip_serializing)] pub password_hash: String, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,}
/// Request body 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,}#[serde(skip_serializing)] on password_hash is the line that keeps a hash out of every JSON response. Put it in the canonical type so no handler can forget it.
How do you give the agent your real schema before it writes SQLx queries?
Section titled “How do you give the agent your real schema before it writes SQLx queries?”query_as! checks each query against a live database at compile time, so a guessed column name fails the build. The agent guesses because it cannot see your schema. A read-only Postgres MCP server lets it inspect the real tables first, so the query matches on the first attempt instead of the fourth.
This recipe uses Postgres MCP Pro, a Python package (postgres-mcp 0.3.0 on PyPI, Python 3.12 or later) that runs with uvx. Its --access-mode=restricted flag limits it to read-only transactions. The only difference between tools is where the configuration lives.
claude mcp add postgres -e DATABASE_URI=postgresql://localhost/myapp_dev \ -- uvx postgres-mcp --access-mode=restrictedThe default scope is local: the server is stored for this project in your user configuration, not in the repository. Add -s project only if you want it in a shared .mcp.json, and never commit a connection string with a password in it. Check the connection with claude mcp list.
codex mcp add postgres --env DATABASE_URI=postgresql://localhost/myapp_dev \ -- uvx postgres-mcp --access-mode=restrictedThis writes an [mcp_servers.postgres] entry to ~/.codex/config.toml. Check it with codex mcp list.
.cursor/mcp.json:
{ "mcpServers": { "postgres": { "command": "uvx", "args": ["postgres-mcp", "--access-mode=restricted"], "env": { "DATABASE_URI": "postgresql://localhost/myapp_dev" } } }}The core of a good answer:
pub async fn find_by_email(&self, email: &str) -> Result<Option<User>> { let user = sqlx::query_as!( User, "SELECT id, email, name, active, password_hash, created_at, updated_at \ FROM users WHERE email = $1", email ) .fetch_optional(&self.pool) .await?; Ok(user)}fetch_optional matters: fetch_one returns sqlx::Error::RowNotFound for a missing user, which the next section has to turn into a 404 instead of a 500. The cargo sqlx prepare --workspace --check step in the gate fails if the committed .sqlx/ metadata no longer matches the queries, which is what keeps CI buildable with SQLX_OFFLINE=true.
How do you map errors to HTTP status codes without leaking internals?
Section titled “How do you map errors to HTTP status codes without leaking internals?”Define the error enum once with thiserror, then implement IntoResponse so axum turns each variant into a status code. Two bugs are common in agent output: RowNotFound returned as a 500, and the raw database error text sent to the client.
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
#[derive(Debug, thiserror::Error)]pub enum AppError { #[error("{0} not found")] NotFound(&'static str), #[error("validation failed: {0}")] Validation(String), #[error("{0} already exists")] Conflict(&'static str), #[error("unauthorized")] Unauthorized, #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] Internal(#[from] anyhow::Error),}
pub type Result<T> = std::result::Result<T, AppError>;
impl IntoResponse for AppError { fn into_response(self) -> Response { let status = match &self { AppError::NotFound(_) | AppError::Database(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND, AppError::Validation(_) => StatusCode::BAD_REQUEST, AppError::Conflict(_) => StatusCode::CONFLICT, AppError::Unauthorized => StatusCode::UNAUTHORIZED, AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, }; // Log the detail; send the client a generic message for every 5xx. let message = if status.is_server_error() { tracing::error!(error = ?self, "request failed"); "internal error".to_string() } else { self.to_string() }; (status, Json(serde_json::json!({ "error": message }))).into_response() }}The handler tests are the proof. They fail if a variant maps to the wrong status or if database text reaches a client, and you never read into_response to find out.
How do you shut down an axum service cleanly?
Section titled “How do you shut down an axum service cleanly?”Ask for “graceful shutdown” and agents often hand-roll a oneshot channel: the server stops accepting connections, but spawned background tasks keep running with nothing to wait for them. Use axum’s with_graceful_shutdown for requests, and a CancellationToken plus TaskTracker from tokio-util for everything else.
axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?;
token.cancel(); // tell background workers to stop taking new worktracker.close(); // no new tasks may be spawnedif tokio::time::timeout(Duration::from_secs(10), tracker.wait()).await.is_err() { tracing::warn!("background tasks did not finish within 10s");}The integration test is the evidence: it fails if the tracker is never awaited, because shutdown returns before the job finishes and the completion assertion sees an unfinished job.
How do you generate Rust tests that catch real bugs?
Section titled “How do you generate Rust tests that catch real bugs?”Name the cases, or you get happy-path tests only. Then add one test against a real Postgres, because a mocked repository cannot catch a wrong column or a missing unique index. #[sqlx::test] creates an isolated test database per test and applies ./migrations automatically when the migrate feature is on.
Asking for the case list before the code gives you the one thing worth reading: the test list. For more on test design with agents, see integration testing and property-based testing.
How do you verify agent-written Rust without reading every line?
Section titled “How do you verify agent-written Rust without reading every line?”Read the gate output and the test list, not the diff. The compiler already rules out memory-safety bugs and data races in safe code, so human attention goes to what it cannot see: logic, error mapping, and whether the tests would notice a regression.
| Gate | Command | What it catches |
|---|---|---|
| Format | cargo fmt --all -- --check | Unformatted files the hook missed |
| Lint | cargo clippy --workspace --all-targets -- -D warnings | Needless borrows and conversions, common correctness and performance lints; .unwrap() in production code once you enable clippy::unwrap_used |
| Query metadata | cargo sqlx prepare --workspace --check | .sqlx/ out of date with the queries, which breaks SQLX_OFFLINE builds in CI |
| Tests | cargo nextest run --workspace and cargo test --doc | Wrong behaviour, wrong status codes, missing constraints |
| Supply chain | cargo deny check | RustSec advisories, disallowed licences, duplicate or banned crates |
| Test strength | cargo mutants --in-diff pr.diff | Changed code whose tests still pass when the code is broken |
| Clone count | git diff main -- '*.rs' | grep -c '^+.*\.clone()' | Clones the agent added to escape the borrow checker |
| Review agent | /code-review (Claude Code), codex review --base main (Codex), Bugbot (Cursor) | Logic errors the gates cannot express |
Generate pr.diff with git diff main > pr.diff. Mutation testing matters most here: a green suite proves only that the tests pass, and cargo mutants shows whether they fail when the changed code is broken. Run the first five gates in CI on every pull request (CI/CD pipeline patterns has the workflow file) and the mutation run on the diff.
The engineer who owns the service signs off on four things: the test-case list, a green gate, a mutation report with no missed mutants in changed code, and a clone count they can explain. Human reading goes to the hunks the review agent flags and to any unsafe, spawn_blocking, or transaction code. Agent PR review covers how to split that work across a team.
When agent-written Rust breaks
Section titled “When agent-written Rust breaks”Each failure below survives a quick look at the diff. Each one has a symptom you can detect and a recovery you can paste.
- Clone and
Arcspam. Symptom: the clone count in the diff jumps, orArc<Mutex<_>>appears around data that only one task touches. Recovery: prompt “Remove every.clone()andArcyou added in this branch. For each one, either change the signature to borrow or explain in one line why ownership must move. Done meansmake checkpasses.” - Stale axum 0.7 idioms. Symptom: the server panics at startup on a route such as
/users/:id, or an extractor carries#[async_trait]. Cause: axum 0.8.0 changed path syntax to/{id}(the old syntax panics deliberately), and axum-core replaced#[async_trait]with return-positionimpl Traitin traits. Recovery: keep the axum rule inAGENTS.md, and prompt “Migrate this crate to axum 0.8:/{param}route syntax, no#[async_trait]on extractors. Change no behaviour.” query_as!fails in CI but not locally. Symptom:set DATABASE_URL to use query macros online, or run cargo sqlx prepare. Cause: the agent changed a query without refreshing.sqlx/. Recovery: runcargo sqlx prepare --workspaceagainst a development database, commit.sqlx/, and setSQLX_OFFLINE=truein CI. The gate’s--checkstep stops it recurring.- The async runtime stalls under load. Symptom: latency spikes on every endpoint while one endpoint is busy. Cause: argon2 hashing,
std::fs, or a blocking HTTP client called directly in anasync fn. Recovery: prompt “Find every CPU-heavy or blocking call inside anasync fnin this workspace and move it intotokio::task::spawn_blocking. List each call you moved.” - Internal errors leak to clients. Symptom: a 500 response body contains SQL, a table name, or a file path. Cause:
self.to_string()used for every status. Recovery: the generic 5xx message ininto_response, plus the handler test that asserts no SQL text in the body. - “Zero-copy” that copies. Symptom: the agent labels a parser zero-copy but it calls
.to_vec()or collects intoVec<u8>per frame. Recovery: ask forbytes::BytesMut::split_to(n).freeze(), which hands out a reference-countedBytesview into the same allocation, or fortokio_util::codec::LengthDelimitedCodecinstead of hand-rolled framing. Prove the gain with a benchmark, not the label. - The agent weakens a test to reach green. Symptom: an assertion loosened, a case deleted, or
#[ignore]added in the same diff as the fix. Recovery: revert the test file, restate the rule fromAGENTS.md, and make CI fail when a pull request modifies, renames or deletes an existing test without atest-changelabel:git diff --name-status --diff-filter=MDR origin/main... -- '*tests/*' | awk -F'\t' '{print $2}'lists those files by the path they had before the change, and protect the oracle shows the full check.
Where to go next with Rust services
Section titled “Where to go next with Rust services”- Make the repository agent-ready first, if it is not yet: agent-ready codebase.
- Choose and lock down a database MCP server: database MCP servers.
- Harden the data layer: SQL optimization patterns and database migration patterns.
- Design the HTTP surface: API development patterns.
- Decide what the agent may run unattended: permissions and sandboxing.
- Compare with another compiled backend: Go language patterns, or return to the backend recipes overview.