Rust for Backend Development in 2026: Axum, SQLx, and the Production Stack



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.

Rust Programming 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:

  1. Memory safety without GC: No GC pauses, no memory leaks, no use-after-free
  2. Performance: Consistently 2-5x faster than Go for CPU-bound work; comparable for I/O-bound
  3. Operational simplicity: Single static binary, minimal Docker image (from scratch)
  4. Correctness at compile time: The type system catches entire classes of bugs before deployment
  5. 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

LayerLibraryAlternative
Async runtimetokioasync-std
HTTP serveraxumactix-web, poem
Database (async)sqlxsea-orm
Serializationserde + serde_json
Validationvalidatorgarde
Error handlingthiserror + anyhowcolor-eyre
Auth/JWTjsonwebtokenpaseto
Tracingtracing + opentelemetry
Testingtokio-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.

Code Editor 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.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)