Rust for Backend Development in 2026: Axum, SQLx, and the Production Stack
on Rust, Backend, Axum, Sqlx, Api, Web development, Systems programming
Rust for Backend Development in 2026: Axum, SQLx, and the Production Stack
Rust’s reputation for systems programming is well-earned, but its web ecosystem has matured to the point where it’s a legitimate choice for backend API development — not just for performance-critical services, but for everyday CRUD applications. This post covers building a production-ready REST API with today’s Rust web stack.
Photo by Markus Spiske on Unsplash
Why Rust for Web APIs in 2026?
The honest answer: Rust is not for everyone or everything. The learning curve is real. Compile times, while improved, are still longer than Go or Node.js. The borrow checker will frustrate you.
But the reasons to use Rust for backend work have strengthened:
- Memory safety without GC: No GC pauses, no memory leaks, no use-after-free
- Performance: Consistently 2-5x faster than Go for CPU-bound work; comparable for I/O-bound
- Operational simplicity: Single static binary, minimal Docker image (from scratch)
- Correctness at compile time: The type system catches entire classes of bugs before deployment
- Ecosystem maturity: Axum, Tokio, SQLx, SeaORM — production-grade tools exist now
The companies using Rust in backend services in 2026: Cloudflare, Discord, Dropbox, 1Password, AWS (Firecracker), Vercel (edge runtime). It’s not experimental.
The 2026 Rust Web Stack
| Layer | Library | Alternative |
|---|---|---|
| Async runtime | tokio | async-std |
| HTTP server | axum | actix-web, poem |
| Database (async) | sqlx | sea-orm |
| Serialization | serde + serde_json | — |
| Validation | validator | garde |
| Error handling | thiserror + anyhow | color-eyre |
| Auth/JWT | jsonwebtoken | paseto |
| Tracing | tracing + opentelemetry | — |
| Testing | tokio-test + axum-test | — |
Project Setup
# Cargo.toml
[package]
name = "my-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["macros", "multipart"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "migrate"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "2"
validator = { version = "0.18", features = ["derive"] }
jsonwebtoken = "9"
dotenv = "0.15"
Application Structure
src/
├── main.rs
├── config.rs # Environment configuration
├── db.rs # Database connection pool
├── error.rs # Error types
├── models/
│ ├── mod.rs
│ └── user.rs
├── handlers/
│ ├── mod.rs
│ └── users.rs
├── middleware/
│ ├── mod.rs
│ └── auth.rs
└── router.rs
migrations/
├── 001_create_users.sql
└── 002_add_sessions.sql
Core Implementation
Error Handling
Good error handling is foundational in Rust web apps:
// error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("unauthorized")]
Unauthorized,
#[error("validation error: {0}")]
Validation(String),
#[error("database error: {0}")]
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 {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
AppError::Database(e) => {
tracing::error!("Database error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
}
AppError::Internal(e) => {
tracing::error!("Internal error: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
pub type Result<T> = std::result::Result<T, AppError>;
Models
// models/user.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use validator::Validate;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: Uuid,
pub email: String,
#[serde(skip_serializing)] // Never serialize password hash
pub password_hash: String,
pub name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize, Validate)]
pub struct CreateUserRequest {
#[validate(email(message = "Invalid email format"))]
pub email: String,
#[validate(length(min = 2, max = 100, message = "Name must be 2-100 characters"))]
pub name: String,
#[validate(length(min = 8, message = "Password must be at least 8 characters"))]
pub password: String,
}
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub name: String,
pub created_at: DateTime<Utc>,
}
impl From<User> for UserResponse {
fn from(user: User) -> Self {
UserResponse {
id: user.id,
email: user.email,
name: user.name,
created_at: user.created_at,
}
}
}
Database Layer with SQLx
// handlers/users.rs
use axum::{
extract::{Path, State},
Json,
};
use sqlx::PgPool;
use uuid::Uuid;
use validator::Validate;
use crate::{
error::{AppError, Result},
models::user::{CreateUserRequest, User, UserResponse},
};
pub async fn get_user(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<UserResponse>> {
let user = sqlx::query_as!(
User,
"SELECT * FROM users WHERE id = $1",
id
)
.fetch_optional(&pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("User {} not found", id)))?;
Ok(Json(user.into()))
}
pub async fn create_user(
State(pool): State<PgPool>,
Json(req): Json<CreateUserRequest>,
) -> Result<Json<UserResponse>> {
// Validate request
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
// Hash password
let password_hash = bcrypt::hash(&req.password, bcrypt::DEFAULT_COST)
.map_err(|e| AppError::Internal(e.into()))?;
let user = sqlx::query_as!(
User,
r#"
INSERT INTO users (id, email, name, password_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
RETURNING *
"#,
Uuid::new_v4(),
req.email.to_lowercase(),
req.name,
password_hash,
)
.fetch_one(&pool)
.await
.map_err(|e| match e {
sqlx::Error::Database(ref dbe) if dbe.constraint() == Some("users_email_key") => {
AppError::Validation("Email already exists".to_string())
}
_ => AppError::Database(e),
})?;
Ok(Json(user.into()))
}
pub async fn list_users(
State(pool): State<PgPool>,
) -> Result<Json<Vec<UserResponse>>> {
let users = sqlx::query_as!(
User,
"SELECT * FROM users ORDER BY created_at DESC LIMIT 100"
)
.fetch_all(&pool)
.await?;
Ok(Json(users.into_iter().map(Into::into).collect()))
}
Router Setup
// router.rs
use axum::{
middleware,
routing::{get, post},
Router,
};
use sqlx::PgPool;
use tower_http::{
compression::CompressionLayer,
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use crate::{
handlers::users,
middleware::auth::require_auth,
};
pub fn create_router(pool: PgPool) -> Router {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
Router::new()
// Public routes
.route("/health", get(|| async { "OK" }))
.route("/api/v1/auth/register", post(users::create_user))
.route("/api/v1/auth/login", post(users::login))
// Protected routes
.route(
"/api/v1/users",
get(users::list_users)
)
.route(
"/api/v1/users/:id",
get(users::get_user).put(users::update_user).delete(users::delete_user),
)
.route_layer(middleware::from_fn_with_state(pool.clone(), require_auth))
.with_state(pool)
.layer(cors)
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
}
Main Entry Point
// main.rs
use sqlx::postgres::PgPoolOptions;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod config;
mod db;
mod error;
mod handlers;
mod middleware;
mod models;
mod router;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
// Initialize tracing
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "my_api=debug,tower_http=debug".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
// Database
let database_url = std::env::var("DATABASE_URL")?;
let pool = PgPoolOptions::new()
.max_connections(20)
.connect(&database_url)
.await?;
// Run migrations
sqlx::migrate!("./migrations").run(&pool).await?;
// Build app
let app = router::create_router(pool);
let addr = "0.0.0.0:8080";
tracing::info!("Listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}
Minimal Docker Image
One of Rust’s production advantages:
# Multi-stage build
FROM rust:1.82-slim as builder
WORKDIR /app
COPY . .
RUN cargo build --release
# Final image: 10-15MB vs 1GB+ for JVM
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/my-api /usr/local/bin/my-api
EXPOSE 8080
CMD ["my-api"]
Result: ~15MB Docker image, no JVM, no Node.js, no interpreter. The binary runs with 5-10MB RSS at idle.
Testing
// Integration testing with axum-test
#[cfg(test)]
mod tests {
use axum_test::TestServer;
use serde_json::json;
#[tokio::test]
async fn test_create_user() {
let pool = setup_test_db().await;
let app = create_router(pool);
let server = TestServer::new(app).unwrap();
let response = server
.post("/api/v1/auth/register")
.json(&json!({
"email": "test@example.com",
"name": "Test User",
"password": "securepassword123"
}))
.await;
response.assert_status_ok();
let body = response.json::<serde_json::Value>();
assert_eq!(body["email"], "test@example.com");
assert!(body.get("password").is_none()); // Password never in response
}
}
Performance in Practice
From a production API at 50k req/s:
- Memory: 8MB RSS (vs 200MB+ for equivalent Spring Boot service)
- Latency P99: 2ms (vs 12ms)
- CPU: 0.3 cores (vs 2 cores)
- Cold start: ~10ms (vs 8-15 seconds for JVM)
The cold start difference alone makes Rust compelling for serverless functions.
When NOT to Use Rust for Web
- Fast iteration required: Rust compile times slow development cycles
- Small team, short deadline: The learning curve costs weeks, not days
- Simple CRUD with business logic: Go or TypeScript will ship faster
- You need dynamic reflection: Rust’s type system makes dynamic behavior painful
The sweet spot: performance-critical services, infrastructure tools, anything deployed at scale where compute costs matter.
Photo by Florian Olivo on Unsplash
Rust’s web ecosystem in 2026 is genuinely production-ready. Axum is ergonomic and fast. SQLx provides compile-time-verified queries. The operational story (tiny binaries, low memory) is excellent. The question isn’t “can Rust do web APIs” anymore — it’s “does the tradeoff make sense for your use case.” For many teams, it does.
이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)
