Skip to content

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.

  • One AGENTS.md rule block for Rust that Claude Code, Codex, and Cursor all follow, plus a make check gate 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.

  1. Pin the toolchain in rust-toolchain.toml. On 2026-09-26 the current stable release is Rust 1.98.1 (per the static.rust-lang.org stable channel manifest). A pin stops the agent from assuming an older compiler, and it matters for SQLx: sqlx 0.9.0 declares rust-version = 1.94 on crates.io.

    [toolchain]
    channel = "1.98.1"
    components = ["rustfmt", "clippy", "rust-analyzer"]
  2. Declare the stack once in the workspace Cargo.toml. The versions are the latest on crates.io on 2026-09-26. Caret requirements let cargo update move patch releases; for a 0.x crate such as "0.9", the caret stays inside 0.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 TaskTracker
    serde = { 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 chrono on sqlx, a DateTime<Utc> field does not compile in query_as!. Without rt on tokio-util, TaskTracker does not exist.

  3. Add the gate as a make check target. It needs DATABASE_URL pointing at a local development Postgres, because both cargo sqlx prepare --check and #[sqlx::test] connect to a server.

    .PHONY: check
    check:
    cargo fmt --all -- --check
    cargo clippy --workspace --all-targets -- -D warnings
    cargo sqlx prepare --workspace --check
    cargo nextest run --workspace
    cargo test --workspace --doc
    cargo deny check

    Install the tools once in your terminal, then generate a starting deny.toml:

    Terminal window
    cargo install --locked cargo-nextest cargo-deny sqlx-cli cargo-mutants
    cargo deny init

    cargo nextest does not run doctests, which is why the gate runs cargo test --doc separately. cargo deny check covers RustSec advisories, licences, and banned or duplicate crates in one command.

  4. Put the Rust rules in AGENTS.md at 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 implement
    FromRequestParts without #[async_trait].
    - Borrow by default (&str, &[T]). Take ownership only when the callee stores the
    value. 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 migration
    change, run `cargo sqlx prepare --workspace` and commit .sqlx/.
    - CPU-heavy or blocking work (password hashing, std::fs) runs in
    tokio::task::spawn_blocking.
    - Never delete or loosen an existing test, and never add #[ignore], to reach
    green. 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.

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.md

From 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:

Terminal window
claude plugin install rust-analyzer-lsp@claude-plugins-official

Before you open a pull request, run /code-review in the session for a second opinion on the diff.

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.

Terminal window
claude mcp add postgres -e DATABASE_URI=postgresql://localhost/myapp_dev \
-- uvx postgres-mcp --access-mode=restricted

The 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.

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 work
tracker.close(); // no new tasks may be spawned
if 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.

GateCommandWhat it catches
Formatcargo fmt --all -- --checkUnformatted files the hook missed
Lintcargo clippy --workspace --all-targets -- -D warningsNeedless borrows and conversions, common correctness and performance lints; .unwrap() in production code once you enable clippy::unwrap_used
Query metadatacargo sqlx prepare --workspace --check.sqlx/ out of date with the queries, which breaks SQLX_OFFLINE builds in CI
Testscargo nextest run --workspace and cargo test --docWrong behaviour, wrong status codes, missing constraints
Supply chaincargo deny checkRustSec advisories, disallowed licences, duplicate or banned crates
Test strengthcargo mutants --in-diff pr.diffChanged code whose tests still pass when the code is broken
Clone countgit 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.

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 Arc spam. Symptom: the clone count in the diff jumps, or Arc<Mutex<_>> appears around data that only one task touches. Recovery: prompt “Remove every .clone() and Arc you added in this branch. For each one, either change the signature to borrow or explain in one line why ownership must move. Done means make check passes.”
  • 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-position impl Trait in traits. Recovery: keep the axum rule in AGENTS.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: run cargo sqlx prepare --workspace against a development database, commit .sqlx/, and set SQLX_OFFLINE=true in CI. The gate’s --check step 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 an async fn. Recovery: prompt “Find every CPU-heavy or blocking call inside an async fn in this workspace and move it into tokio::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 in into_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 into Vec<u8> per frame. Recovery: ask for bytes::BytesMut::split_to(n).freeze(), which hands out a reference-counted Bytes view into the same allocation, or for tokio_util::codec::LengthDelimitedCodec instead 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 from AGENTS.md, and make CI fail when a pull request modifies, renames or deletes an existing test without a test-change label: 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.