Skip to content

Topic · A3

Cursor Rules for Rust + Axum + sqlx (2026)

Most Cursor rules for Rust are 8 lines and say `idiomatic Rust`. Here is the Axum + sqlx + tower production rule that catches `.unwrap()` in handlers, missing error mappings, and N+1 queries before the build.

# Cursor Rules for Rust + Axum + sqlx Search "cursor rules rust" and the top result is Pieter Levels' generic Rust rule on cursor.directory. It's eight lines. It says things like "use idiomatic Rust" and "handle errors with Result". The forum has a separate thread of community gists trying to fill the gap. None of them are specific to a production stack. This page is the rule we ship internally for an Axum HTTP service backed by sqlx and Postgres. Tower for middleware. thiserror for errors. tracing for logs. The full rule body is at the bottom; each section above it explains the directive and shows a before/after diff against what Cursor produces without it.

Why "idiomatic Rust" isn't a rule

The phrase tells the agent nothing. Idiomatic Rust depends entirely on the stack: an embassy embedded project, a bevy game, an axum web server, and a cargo-mutants test runner all have different idioms. A real rule has to say: this project is Axum + sqlx + tokio. Apply the Axum idioms. The directives below name three pieces of friction Cursor produces every time without that anchor:
  1. Panics in handlers. Cursor reaches for .unwrap(), .expect(), and panic!() inside handlers because the simplest path past a Result<> is to assume the happy case. Each panic crashes the request-processing task and produces an unhelpful 500.
  1. Inconsistent error types. Without an explicit project error enum, the agent invents one per handler — sometimes Box, sometimes a tuple (StatusCode, String), sometimes a freshly-derived MyHandlerError. None of them implement IntoResponse correctly.
  1. Runtime SQL where compile-time SQL exists. sqlx's query!/query_as! macros are the load-bearing reason to choose sqlx over alternatives. The agent will reach for the runtime sqlx::query() builder by default — that gives up the type checks and the schema validation.
The rule below treats each as a named directive.

The six directives that change the most output

1. No .unwrap(), .expect(), or panic!() in handlers.

Before (Cursor without rule): ``rust async fn get_user(Path(id): Path, State(db): State) -> Json { let user = sqlx::query_as!(User, "SELECT FROM users WHERE id = $1", id) .fetch_one(&db) .await .unwrap(); Json(user) } ` After: `rust async fn get_user( Path(id): Path, State(db): State, ) -> Result, AppError> { let user = sqlx::query_as!(User, "SELECT FROM users WHERE id = $1", id) .fetch_optional(&db) .await? .ok_or(AppError::NotFound)?; Ok(Json(user)) } ` The rule body adds: Handlers MUST return Result. .unwrap(), .expect(), and panic!() are forbidden inside handlers, middleware, and any function called transitively from a handler. They are allowed in main(), integration tests, and build.rs. Use ? with a From for AppError impl to propagate. The fetch_optional over fetch_one is the second half of the same fix: fetch_one errors when no row exists, which the agent then unwraps into a 500. fetch_optional returns Option and the .ok_or(AppError::NotFound)? converts the missing-row case into a 404.

2. One project-level error enum.

Before:
`rust async fn create_post() -> Result, (StatusCode, String)> { // mixed types, status codes invented per handler } async fn get_user() -> Result, Box> { // different type, no IntoResponse impl } ` After: `rust // src/error.rs use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; use thiserror::Error; #[derive(Debug, Error)] pub enum AppError { #[error("resource not found")] NotFound, #[error("invalid input: {0}")] BadRequest(String), #[error("unauthorized")] Unauthorized, #[error("database error")] Database(#[from] sqlx::Error), #[error("internal error")] Internal(#[from] anyhow::Error), } impl IntoResponse for AppError { fn into_response(self) -> Response { let (status, message) = match &self { Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()), Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), Self::Database(e) => { tracing::error!(error = ?e, "database error"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) } Self::Internal(e) => { tracing::error!(error = ?e, "internal error"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) } }; (status, Json(serde_json::json!({ "error": message }))).into_response() } } ` The rule body says: All handlers return Result where AppError lives in src/error.rs (or src/errors/mod.rs). New error cases are added to the enum, never invented inline. Database errors are logged with tracing::error! before being mapped to a generic 500 — never expose the underlying sqlx::Error message to the client.

3. sqlx macros, not the runtime builder.

Before:
`rust let rows = sqlx::query("SELECT id, name FROM users WHERE org_id = $1") .bind(org_id) .fetch_all(&db) .await?; let users: Vec = rows.into_iter().map(|r| User { id: r.get("id"), name: r.get("name"), }).collect(); ` After: `rust let users = sqlx::query_as!( User, "SELECT id, name FROM users WHERE org_id = $1", org_id ) .fetch_all(&db) .await?; ` The rule body adds: Use sqlx::query!, sqlx::query_as!, or sqlx::query_scalar! for all SQL with statically-known shape. The runtime sqlx::query() builder is only allowed for genuinely dynamic SQL (column list varies by request, ORDER BY column varies). CI runs cargo sqlx prepare --check against the offline metadata so the build doesn't require a live database.

4. Extractors, in the right order.

Axum extractors run in the order they're listed in the handler signature. Some extractors consume the request body — only one body-consuming extractor is allowed, and it must be last. Before:
`rust async fn handler( Json(body): Json, // consumes the body Path(id): Path, // extracts from URL State(db): State, // extracts from app state ) -> Result, AppError> { / ... / } ` This compiles but the body extractor runs first and Path/State extractors after — fine in practice, but if you ever add a second body extractor (e.g. axum::extract::Multipart) it will silently fail. The rule body says: Order extractors in the handler signature as: Path → Query → State → Extension → headers/cookies → body. The body-consuming extractor (Json, Form, Multipart, Bytes, String) MUST be last.

5. Compose middleware in main(), not by hand.

Before: `rust // some_middleware.rs — a hand-rolled tower::Service impl struct AuthLayer; impl Layer for AuthLayer { / 40 lines / } struct AuthService { inner: S } impl Service for AuthService { / another 30 lines / } ` After: `rust // src/middleware/auth.rs use axum::{extract::Request, middleware::Next, response::Response, http::StatusCode}; pub async fn require_auth(req: Request, next: Next) -> Result { let header = req.headers().get("authorization") .and_then(|v| v.to_str().ok()) .ok_or(StatusCode::UNAUTHORIZED)?; if !header.starts_with("Bearer ") { return Err(StatusCode::UNAUTHORIZED); } // validate token... Ok(next.run(req).await) } // src/main.rs use tower::ServiceBuilder; use tower_http::{cors::CorsLayer, trace::TraceLayer, timeout::TimeoutLayer}; let app = Router::new() .route("/protected", get(protected_handler)) .route_layer(axum::middleware::from_fn(require_auth)) .layer( ServiceBuilder::new() .layer(TraceLayer::new_for_http()) .layer(TimeoutLayer::new(Duration::from_secs(30))) .layer(CorsLayer::permissive()) ); ` The rule body says: Custom middleware uses axum::middleware::from_fn or from_fn_with_state. Do not hand-roll tower::Service impls. Compose tower middleware in main() via ServiceBuilder in order: trace → timeout → CORS → auth-not-applicable-to-all → routes.

6. Spans, not println!.

The rule body says: Use tracing for all logging. Each handler gets #[tracing::instrument(skip(db))]. Database errors log with tracing::error!(error = ?e, ...). No println!, no eprintln!, no dbg! in committed code.

The full rule (paste into .cursor/rules/rust-axum.mdc)

`mdc
description: Rust + Axum + sqlx production rules. Tokio runtime, Postgres backend. globs: - "src/*/.rs" - "Cargo.toml" - "migrations/*/.sql" alwaysApply: false
# Rust + Axum + sqlx Rules

Project contract

This is an Axum HTTP service on tokio, backed by sqlx + Postgres. Errors use a single
AppError enum with thiserror::Error + IntoResponse. Logs use tracing. Middleware is tower::ServiceBuilder composition in main().

Banned constructs

  • .unwrap(), .expect(), panic!() inside any function called from a handler. Allowed only in main(), integration tests, and build.rs.
  • Box or anyhow::Result as a handler return type. Use Result.
  • Hand-rolled tower::Service impls for one-off middleware. Use axum::middleware::from_fn.
  • Runtime sqlx::query() builder when the column list is statically known. Use the macros.
  • println!, eprintln!, dbg! in committed code.
  • Tuple struct error returns (StatusCode, String).
  • Exposing raw sqlx::Error Display output to clients.

Required patterns

  • Handlers return Result, AppError> where AppError lives in src/error.rs.
  • Extractor order: Path → Query → State → Extension → headers → body (body-consuming extractor last).
  • sqlx macros: query!, query_as!, query_scalar! for all known-shape SQL.
  • fetch_optional().await?.ok_or(AppError::NotFound)? for single-row lookups that may miss.
  • #[tracing::instrument(skip(db, ...))] on every handler.
  • Migrations in migrations/ directory, applied via sqlx::migrate!() macro at startup.
  • cargo sqlx prepare --check in CI for offline-mode build verification.
  • Arc (not Arc>) for shared state. PgPool is Clone and internally synchronized.

Required dependencies (versions floating)

`toml axum = "0.7" tokio = { version = "1", features = ["full"] } tower = "0.5" tower-http = { version = "0.6", features = ["trace", "cors", "timeout"] } sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "macros"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" anyhow = "1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } `

Test conventions

  • Integration tests in tests/ use sqlx::PgPool against a test database; transactions roll back via sqlx::test.
  • Mocking the database is forbidden. The test DB is cheap; use it.
  • #[tokio::test] for async tests. #[tokio::test(flavor = "multi_thread")] only when needed.

When the user asks for "a quick handler"

Generate the
Result, AppError> shape and #[tracing::instrument] regardless of complexity. The boilerplate is the rule, not an optional polish step. `

Where this rule fails

1.
sqlx::query! requires a live database for offline metadata. First-time setup is awkward: the macros either need a DATABASE_URL env var pointing at a real Postgres or a sqlx-data.json (sqlx 0.7) / .sqlx/ directory (sqlx 0.8) committed to the repo. If your CI doesn't have either, the build breaks. The rule body says to run cargo sqlx prepare in CI but doesn't fix it for you — you have to set up the offline metadata once. 2. IntoResponse impls drift. New error variants are added all the time, and Cursor will sometimes add the variant without updating the IntoResponse match. The match is exhaustive (Rust enforces it) so the build will catch it — but the status code mapping for the new variant is sometimes wrong (a validation error mapped to 500 instead of 422). Code review every new AppError variant. 3. The Database(sqlx::Error) variant is too broad. Anything sqlx-related becomes a 500. A unique_violation on insert should be a 409, a foreign_key_violation should be a 400, a serialization_failure should be a 409 with retry advice. The rule body doesn't fix this — it just covers the base case. A real production project adds variants like AppError::Conflict(String) and pattern-matches sqlx::Error::Database(db_err) to detect the specific Postgres error code. 4. tracing::instrument(skip(db)) doesn't catch every secret. Auth tokens, password fields, and PII can still appear in the request body that gets logged. The rule body covers db because that's the most common false-positive in the span, but production usage adds skip(password, token, credit_card) per handler as needed.

What to read next

Sources

  • Axum team. Axum documentation. Extractor ordering, handler return types, IntoResponse contract.

Related GitHub projects

Frequently asked

Why are Rust Cursor rules so thin compared to TypeScript ones?
Two reasons. First, the Rust developer audience is smaller and writes fewer rule files (cursor.directory has roughly twenty TypeScript rules and one Rust rule by Pieter Levels at the time of writing). Second, `idiomatic Rust` is a deceptively easy thing to write into a rule — but it means nothing to the agent. Cursor needs concrete prohibitions (`.unwrap()` in handlers, `?` without a `From<E>` impl) and concrete templates (the Axum handler shape, the sqlx query macros). The rule below is the one we use in production on a Tauri 2 + Axum service.
Should the rule ban `.unwrap()` outright?
In handlers, middleware, and library code — yes. In `main()`, integration tests, and `build.rs` — no. The rule body marks the exceptions explicitly. The reasoning: `.unwrap()` inside a request handler panics the entire request-processing task, which Axum surfaces as a 500 without the structured error response. The agent reaches for `.unwrap()` constantly because it's the shortest path past a `Result<>`. The rule has to make it a named violation.
Does the rule require `sqlx::query!` macros or string SQL?
Macros (`sqlx::query!`, `sqlx::query_as!`, `sqlx::query_scalar!`). They compile-time-check the SQL against the database schema, return typed columns, and catch most N+1 patterns at the query-shape level. The rule body forbids the runtime `sqlx::query()` builder except for genuinely dynamic SQL (where the columns vary by request), and requires `sqlx prepare` to run in CI so the macros work without a live database connection.
How does the rule handle errors?
A single project-level error enum (`AppError`) with `thiserror::Error`, plus an `IntoResponse` impl that maps each variant to a status code and a structured JSON body. Handlers return `Result<Json<T>, AppError>`. The rule body has the full template — it's the single most-copy-pasted piece of the rule because Cursor without it invents a different error type per handler.
What about tower middleware — does the rule cover it?
Yes, briefly. The rule says: middleware uses `tower::ServiceBuilder` composed in `main()`, with the order being CORS → trace → timeout → auth → app routes. It bans `tower::Service` impls written by hand for one-off middleware (use `axum::middleware::from_fn` instead). For tracing it requires `tower_http::trace::TraceLayer` with `make_span_with` setting the request_id.

Related topics