A Rust API Server That Catches SQL Errors at Build Time — Axum + SQLx Type-Safe Patterns Explained
If you've worked with Go or Node.js backends long enough, you've probably experienced this at least once: a schema change causes a missed query update and a 500 error blows up in production, or a bad type cast hides an error until runtime. "High test coverage will save us," you think — but in practice, edge-case queries have a way of slipping through the tests.
In the Rust ecosystem, the Axum + SQLx combination solves this problem in a fundamentally different way. If your SQL syntax is wrong, a column name changes, or a return type doesn't match — cargo build itself fails. It's not a runtime error; it's DB schema and Rust types validated simultaneously at build time.
Because Rust has no GC, structural advantages in memory usage and tail latency stability compared to Go and Node.js are a consistent trend across multiple benchmarks. That said, actual numbers vary significantly depending on hardware, OS, DB configuration, and load patterns, so it's important to measure against your own workload before adopting.
This article explains how Axum and SQLx work internally, and walks through commonly used patterns — State sharing, connection pool tuning, transaction handling, and CI/CD environment setup — with code examples. If you've used Go or Node.js, some concepts will feel familiar and others will seem strange at first; I'll try to highlight those differences as we go.
Core Concepts
Axum: A Web Framework Built on Tower Service
Axum is a web framework created and maintained directly by the Tokio team. The approach of connecting handlers to a router is similar to Express or Gin, but the internal design is different.
Axum's core design philosophy is to provide a type-safe Extractor system on top of Tower's Service trait. All middleware sits on top of the tower::Service trait, so you can compose Tower ecosystem components without any additional implementation. The concept is similar to Express's app.use() or Go's net/http middleware chain, but because Tower's chain is composed at the type level, middleware composition correctness is verified at compile time rather than runtime.
Axum also officially provides convenience macros like #[debug_handler]. It's a tool that makes compiler messages much more readable when handler type errors occur, and you can use it by enabling axum = { features = ["macros"] } in your Cargo.toml.
SQLx: A Type-Safe SQL Toolkit
SQLx is not an ORM that defines schemas with Rust structs like Diesel. You write SQL directly. However, it asks the DB at compile time whether that SQL is valid.
When you use the query! macro, at cargo build time it sends the SQL to the DB connected via DATABASE_URL for parsing and retrieves type metadata for the returned columns. It uses PostgreSQL's extended query protocol to have the DB itself infer parameter types, so Rust code doesn't need to re-implement SQL parsing logic. It then auto-generates Rust type code based on this metadata.
query! returns an anonymous struct, while query_as! maps directly to a struct you define yourself. In production, query_as! is more explicit and better for code readability.
Type verification is performed identically even when SQLX_OFFLINE=true. Instead of connecting directly to the DB, it reads metadata from pre-generated .sqlx/ cache files and runs the same type checks. This is why type safety can be maintained in CI environments without a DB.
Compared to Go's database/sql, both write SQL directly, but database/sql performs result mapping through interface{}, so type errors aren't known until runtime. SQLx pulls that gap forward to compile time.
Connection Pool Configuration
Create a pool with PgPoolOptions and inject it into handlers via Axum's State extractor.
| Parameter | Recommended Value | Description |
|---|---|---|
max_connections |
20 | Max concurrent connections. Set considering PostgreSQL's default limit (100) |
min_connections |
5 | Number of warmed connections. Prevents latency spikes during bursts |
max_lifetime |
1800s | Connection lifetime. Prevents memory leaks on the DB side |
idle_timeout |
300s | Timer for reclaiming idle connections |
acquire_timeout |
30s | Pool wait timeout. Returns an error if this time is exceeded |
Setting max_connections too high can cause the DB server to reject connections. Since PostgreSQL's default limit is 100, in environments where multiple server instances are running, it's important to calculate per-instance connections as (DB limit - management headroom) / number of instances.
Practical Application
Project Setup
Add dependencies to Cargo.toml.
[dependencies]
axum = { version = "0.8", features = ["macros"] }
sqlx = { version = "0.8", features = [
"runtime-tokio-native-tls",
"postgres",
"macros",
"chrono",
"uuid",
] }
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "2"sqlx includes no DB drivers by default. You must explicitly enable postgres, runtime-tokio-native-tls, and macros. chrono and uuid are required to map those types directly to query results.
Application Startup and State Sharing
The way Axum passes a DB pool to handlers is similar to dependency injection in Go. However, Rust's type system validates shared state at compile time.
use axum::{Router, routing::get};
use sqlx::PgPool;
#[derive(Clone)]
struct AppState {
db: PgPool,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter("info,sqlx=warn")
.init();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.min_connections(5)
.max_lifetime(std::time::Duration::from_secs(1800))
.idle_timeout(std::time::Duration::from_secs(300))
.acquire_timeout(std::time::Duration::from_secs(30))
.connect(&database_url)
.await?;
// If migration fails, ? propagates the error and terminates the process.
// It's safer to fail fast rather than start the server in a broken migration state.
sqlx::migrate!("./migrations").run(&pool).await?;
let state = AppState { db: pool };
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user))
.layer(tower_http::trace::TraceLayer::new_for_http())
.layer(
tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any) // Specify explicit origins in production
)
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
tracing::info!("Server started: http://0.0.0.0:3000");
axum::serve(listener, app).await?;
Ok(())
}The reason #[derive(Clone)] is placed on AppState is that Axum internally wraps the state in an Arc and clones it for each request. PgPool itself is already an Arc<internal state> structure, so the clone cost is negligible. Be careful not to double-wrap by wrapping AppState in Arc<AppState> yourself before passing it to with_state.
Writing Type-Safe Query Handlers
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Serialize)]
struct User {
id: Uuid,
email: String,
name: String,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize)]
struct CreateUserRequest {
email: String,
name: String,
}
async fn list_users(
State(state): State<AppState>,
) -> Result<Json<Vec<User>>, AppError> {
let users = sqlx::query_as!(
User,
"SELECT id, email, name, created_at FROM users ORDER BY created_at DESC"
)
.fetch_all(&state.db)
.await?;
Ok(Json(users))
}
async fn get_user(
Path(id): Path<Uuid>,
State(state): State<AppState>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, email, name, created_at FROM users WHERE id = $1",
id
)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(user))
}
async fn create_user(
State(state): State<AppState>,
Json(req): Json<CreateUserRequest>,
) -> Result<(StatusCode, Json<User>), AppError> {
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (id, email, name, created_at)
VALUES ($1, $2, $3, NOW())
RETURNING id, email, name, created_at
"#,
Uuid::new_v4(),
req.email,
req.name
)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(user)))
}fetch_optional returns None when there is no result. Unlike Go's sql.ErrNoRows, which expresses the absence of a row through the error path, Option<T> represents the presence or absence of a result as a value within the type system. Both approaches handle "no row found," but in Rust, this is treated as part of a normal value rather than an error.
The $1, $2 bind parameters are type-checked at compile time. Passing a Uuid where a String is expected will cause the build to fail. Multi-line SQL can be written cleanly using r#"..."# raw string literals.
Error Handling Pattern
Axum only requires that a handler returns a type implementing IntoResponse. Defining domain errors with thiserror and implementing IntoResponse keeps error handling clean.
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("Resource not found")]
NotFound,
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Invalid request: {0}")]
BadRequest(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound => {
(StatusCode::NOT_FOUND, "Resource not found".to_string())
}
AppError::Database(e) => {
// Internal error cause is logged server-side only, not exposed to the client
tracing::error!(error = %e, "Database error occurred");
(StatusCode::INTERNAL_SERVER_ERROR, "An internal server error occurred".to_string())
}
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}The Display implementation defined by #[error("Database error: {0}")] is for internal logging, while the HTTP response message uses a separate sanitized string that is safe to expose to the client. This is precisely why the two messages are kept distinct. Adding #[from] sqlx::Error enables automatic conversion via the ? operator.
Transaction Handling
Use pool.begin() to process multiple queries atomically. Since the transaction object automatically rolls back when dropped, you must explicitly call commit() for changes to be saved.
async fn transfer_credits(
State(state): State<AppState>,
Json(req): Json<TransferRequest>,
) -> Result<StatusCode, AppError> {
let mut tx = state.db.begin().await?;
let result = sqlx::query!(
"UPDATE accounts SET credits = credits - $1 WHERE id = $2 AND credits >= $1",
req.amount,
req.from_id
)
.execute(&mut *tx)
.await?;
// If the WHERE condition is not met (insufficient balance), affected rows will be 0 — this must be checked explicitly.
// Without this check, a bug can occur where a deposit executes without a corresponding withdrawal.
if result.rows_affected() == 0 {
return Err(AppError::BadRequest("Insufficient balance".into()));
// Returning here causes tx to be dropped and automatically rolled back
}
sqlx::query!(
"UPDATE accounts SET credits = credits + $1 WHERE id = $2",
req.amount,
req.to_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(StatusCode::OK)
}Let me highlight why checking rows_affected() matters. When the WHERE credits >= $1 condition is not satisfied, the UPDATE modifies no rows and silently succeeds. If you skip this check, you get a critical bug where the withdrawal fails but the deposit query still executes.
Passing a transaction reference with &mut *tx may feel unfamiliar at first — it's the pattern of dereferencing because tx implements the Executor trait. Here too, the compiler catches incorrect usage.
Offline Mode in CI/CD
For build servers without a DB, SQLx provides offline mode. You snapshot the metadata locally and use it in CI.
# Generate metadata locally (requires DB connection)
cargo sqlx prepare
# Commit the generated .sqlx/ directory to git
git add .sqlx/
git commit -m "Update sqlx offline metadata"In CI environments, setting SQLX_OFFLINE=true causes the same type verification to run by reading metadata from .sqlx/ files, without needing a DATABASE_URL.
# .github/workflows/ci.yml
- name: Build
env:
SQLX_OFFLINE: "true"
run: cargo build --releaseWhenever the schema changes or queries are added or modified, you must re-run cargo sqlx prepare and commit. If you forget this, CI will produce the error "offline mode is enabled but the query data could not be found". Adding a CI step that checks consistency between .sqlx/ and source queries at the PR stage can prevent this mistake.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Compile-time SQL validation | Eliminates runtime SQL errors at the source. Schema changes immediately cause build failures, enabling regression detection |
| GC-free performance | Structurally advantageous tail latency stability compared to Go and Node.js. Actual numbers vary by environment, so direct measurement is necessary |
| Low memory usage | Advantageous compared to Node.js and Go in container environments. Real-world validation recommended |
| Tower ecosystem | Compose auth, compression, CORS, and tracing as layers. No need to implement middleware from scratch |
| End-to-end type safety | Extractors, handlers, and middleware are all verified by the compiler |
Disadvantages and Caveats
| Item | Details |
|---|---|
| Compile time | Initial builds are several times slower than Go and Node.js. Incremental builds are fast, but CI cold starts are a burden |
| Learning curve | Takes time to get comfortable with Tower's Service/Layer traits and Rust lifetimes |
| Development environment complexity | DATABASE_URL is required in default mode. Offline mode requires managing .sqlx/ synchronization |
| No ORM | Complex joins and relationship mapping must be written in raw SQL. Can involve more boilerplate than Diesel |
| Error type complexity | Tower middleware errors must be Infallible or handled with HandleErrorLayer |
Common Mistakes in Practice
1. Overprovisioning max_connections
PostgreSQL's default connection limit is 100. If 3 server instances are running and each is configured with max_connections(50), the DB will start rejecting connections. Calculate as (DB limit - management headroom) / number of instances.
2. Forgetting to commit .sqlx/ files
If you add or modify a query without running cargo sqlx prepare, the build will fail in CI. Make it a habit to run prepare whenever a query change is included in a PR, or add a consistency check step in CI.
3. Double-wrapping Arc
If you wrap AppState in Arc<AppState> yourself and pass it to with_state, Axum internally wraps it in Arc again, resulting in double-wrapping. Since PgPool is already Arc-based, simply #[derive(Clone)] on AppState itself is sufficient.
4. Ignoring rows_affected() in transactions
A conditional UPDATE (e.g., WHERE stock >= amount) silently succeeds when the condition is not met. Not checking the number of affected rows leads to logic bugs like double-processing balances.
Closing Thoughts
The Axum + SQLx + Tokio trio is one of the most actively growing Rust backend stacks. Alternatives like Actix-web remain strong, and there's no explicit "standard" in the Rust backend ecosystem, but the core value of this stack is clear.
- Catch SQL errors at build time: As a
cargo buildfailure, not a runtime 500 error. - GC-free performance: Structurally advantageous tail latency stability in high-traffic environments.
- The type system protects the architecture: The compiler validates end-to-end, from request parsing to DB queries.
There is a learning curve, and there is a compile-time cost. For teams where rapid prototyping matters, Go or Node.js remain realistic choices. But if schema stability is important, or if your service is bottlenecked on memory and latency, it's well worth evaluating.
If you want to get started right now, here's the recommended order:
- Install
sqlx-cliand create a migration file withcargo sqlx migrate add init - Clone the
launchbadge/realworld-axum-sqlxrepo and browse the structure — it covers auth, error handling, and migration patterns all in one place - Re-implement one endpoint from an existing Go or Node.js service in Axum to get a feel for the difference
References
- SQLx GitHub (launchbadge/sqlx)
- axum official documentation (docs.rs)
- SQLx PoolOptions API documentation (docs.rs)
- Unraveling sqlx Macros: Compile-Time SQL Verification — Leapcell
- SQLx Offline Mode (Prepare Command) — DeepWiki
- Efficient DB Connection Management with sqlx and bb8/deadpool — Leapcell
- realworld-axum-sqlx (official RealWorld implementation)
- The Ultimate Guide to Axum (Shuttle.dev, 2025)
- How to Write Compile-Time Checked Queries with SQLx — RustFAQ