Skip to content
· 11 min read · 0 views

High-Performance APIs with Rust and Axum

Building blazing-fast, type-safe REST APIs with Rust, Axum, and Tokio — from basic routing to production-ready services with middleware, database integration, and testing.

// table of contents (15 sections)

I have built APIs in Go, Node.js, Python, and Java. Rust with Axum is different. The type system catches errors at compile time that would crash other languages at runtime. The performance is consistent and predictable. Zero-cost abstractions mean you do not pay for what you do not use.

This post covers how I build production APIs with Rust and Axum — from setup to deployment, with all the patterns I use in real projects.

Why Rust for APIs?

Rust offers unique advantages for backend services:

  • Zero-cost abstractions — High-level code, low-level performance
  • Fearless concurrency — Tokio’s async runtime handles thousands of connections
  • Type safety — Catch errors at compile time, not in production
  • Memory safety without GC — No garbage collection pauses
  • Minimal runtime — Small binaries, fast startup, perfect for containers
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Create project
cargo new api-server
cd api-server

Project Structure

A clean structure for scalable Rust APIs:

api-server/
├── Cargo.toml
├── src/
│   ├── main.rs              # Entry point
│   ├── lib.rs               # Library root
│   ├── config.rs            # Configuration
│   ├── error.rs             # Error types
│   ├── db.rs                # Database pool
│   ├── handlers/            # HTTP handlers
│   │   ├── mod.rs
│   │   ├── user.rs
│   │   └── health.rs
│   ├── models/              # Data models
│   │   ├── mod.rs
│   │   └── user.rs
│   ├── routes/              # Route definitions
│   │   └── mod.rs
│   ├── middleware/          # Custom middleware
│   │   └── mod.rs
│   └── services/            # Business logic
│       └── mod.rs
└── tests/                   # Integration tests
    └── integration_test.rs

Basic Setup with Axum

Add dependencies to Cargo.toml:

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }

[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }

Basic server in src/main.rs:

// src/main.rs
use axum::{
    routing::{get, post},
    http::StatusCode,
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;

#[derive(Serialize)]
struct HealthResponse {
    status: String,
    version: String,
}

async fn health() -> Json<HealthResponse> {
    Json(HealthResponse {
        status: "ok".to_string(),
        version: env!("CARGO_PKG_VERSION").to_string(),
    })
}

#[derive(Deserialize, Serialize)]
struct CreateUser {
    name: String,
    email: String,
}

#[derive(Serialize)]
struct User {
    id: String,
    name: String,
    email: String,
}

async fn create_user(
    Json(payload): Json<CreateUser>,
) -> Result<Json<User>, StatusCode> {
    let user = User {
        id: uuid::Uuid::new_v4().to_string(),
        name: payload.name,
        email: payload.email,
    };
    
    Ok(Json(user))
}

#[tokio::main]
async fn main() {
    // Initialize tracing
    tracing_subscriber::fmt()
        .with_env_filter("api_server=debug,tower_http=debug")
        .init();

    // Build router
    let app = Router::new()
        .route("/health", get(health))
        .route("/users", post(create_user));

    // Start server
    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
    tracing::debug!("listening on {}", addr);
    
    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Type-Safe Error Handling

Axum uses IntoResponse for HTTP responses. Define error types:

// src/error.rs
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum ApiError {
    #[error("Not found: {0}")]
    NotFound(String),
    
    #[error("Bad request: {0}")]
    BadRequest(String),
    
    #[error("Internal server error: {0}")]
    Internal(String),
    
    #[error("Unauthorized: {0}")]
    Unauthorized(String),
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, error_message) = match self {
            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
            ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
            ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
        };
        
        let body = Json(json!({
            "error": error_message,
            "status": status.as_u16(),
        }));
        
        (status, body).into_response()
    }
}

// Result type alias
pub type Result<T> = std::result::Result<T, ApiError>;

Database Integration with SQLx

Add SQLx for type-safe database queries:

# Add to Cargo.toml
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }

Database pool setup:

// src/db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration;

pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
    PgPoolOptions::new()
        .max_connections(20)
        .min_connections(5)
        .acquire_timeout(Duration::from_secs(3))
        .connect(database_url)
        .await
}

User model with database operations:

// src/models/user.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;

#[derive(Debug, FromRow, Serialize, Deserialize)]
pub struct User {
    pub id: Uuid,
    pub name: String,
    pub email: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
pub struct CreateUser {
    pub name: String,
    pub email: String,
}

#[derive(Debug, Deserialize)]
pub struct UpdateUser {
    pub name: Option<String>,
    pub email: Option<String>,
}

impl User {
    pub async fn create(pool: &sqlx::PgPool, input: CreateUser) -> Result<Self, sqlx::Error> {
        let user = sqlx::query_as::<_, Self>(
            r#"
            INSERT INTO users (name, email)
            VALUES ($1, $2)
            RETURNING *
            "#,
        )
        .bind(&input.name)
        .bind(&input.email)
        .fetch_one(pool)
        .await?;
        
        Ok(user)
    }
    
    pub async fn find_by_id(pool: &sqlx::PgPool, id: Uuid) -> Result<Option<Self>, sqlx::Error> {
        let user = sqlx::query_as::<_, Self>(
            "SELECT * FROM users WHERE id = $1",
        )
        .bind(id)
        .fetch_optional(pool)
        .await?;
        
        Ok(user)
    }
    
    pub async fn find_all(pool: &sqlx::PgPool, limit: i64, offset: i64) -> Result<Vec<Self>, sqlx::Error> {
        let users = sqlx::query_as::<_, Self>(
            "SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2",
        )
        .bind(limit)
        .bind(offset)
        .fetch_all(pool)
        .await?;
        
        Ok(users)
    }
    
    pub async fn update(pool: &sqlx::PgPool, id: Uuid, input: UpdateUser) -> Result<Self, sqlx::Error> {
        let user = sqlx::query_as::<_, Self>(
            r#"
            UPDATE users
            SET 
                name = COALESCE($1, name),
                email = COALESCE($2, email),
                updated_at = NOW()
            WHERE id = $3
            RETURNING *
            "#,
        )
        .bind(&input.name)
        .bind(&input.email)
        .bind(id)
        .fetch_one(pool)
        .await?;
        
        Ok(user)
    }
    
    pub async fn delete(pool: &sqlx::PgPool, id: Uuid) -> Result<(), sqlx::Error> {
        sqlx::query("DELETE FROM users WHERE id = $1")
            .bind(id)
            .execute(pool)
            .await?;
        
        Ok(())
    }
}

Handlers with Database

Handlers use state to access the database:

// src/handlers/user.rs
use axum::{
    extract::{Path, Query, State},
    http::StatusCode,
    Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
    db::DbPool,
    error::{ApiError, Result},
    models::user::{CreateUser, UpdateUser, User},
};

#[derive(Debug, Deserialize)]
pub struct ListQuery {
    #[serde(default = "default_limit")]
    limit: i64,
    #[serde(default)]
    offset: i64,
}

fn default_limit() -> i64 { 20 }

#[derive(Serialize)]
pub struct ListResponse<T> {
    data: Vec<T>,
    meta: Meta,
}

#[derive(Serialize)]
pub struct Meta {
    limit: i64,
    offset: i64,
    count: usize,
}

pub async fn list_users(
    State(pool): State<DbPool>,
    Query(query): Query<ListQuery>,
) -> Result<Json<ListResponse<User>>> {
    let users = User::find_all(&pool, query.limit, query.offset)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    
    let count = users.len();
    
    Ok(Json(ListResponse {
        data: users,
        meta: Meta {
            limit: query.limit,
            offset: query.offset,
            count,
        },
    }))
}

pub async fn get_user(
    State(pool): State<DbPool>,
    Path(id): Path<Uuid>,
) -> Result<Json<User>> {
    let user = User::find_by_id(&pool, id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?
        .ok_or_else(|| ApiError::NotFound(format!("User with id {} not found", id)))?;
    
    Ok(Json(user))
}

pub async fn create_user(
    State(pool): State<DbPool>,
    Json(input): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>)> {
    let user = User::create(&pool, input)
        .await
        .map_err(|e| {
            if e.to_string().contains("duplicate key") {
                ApiError::BadRequest("Email already exists".to_string())
            } else {
                ApiError::Internal(e.to_string())
            }
        })?;
    
    Ok((StatusCode::CREATED, Json(user)))
}

pub async fn update_user(
    State(pool): State<DbPool>,
    Path(id): Path<Uuid>,
    Json(input): Json<UpdateUser>,
) -> Result<Json<User>> {
    // Check if user exists
    let _ = User::find_by_id(&pool, id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?
        .ok_or_else(|| ApiError::NotFound(format!("User with id {} not found", id)))?;
    
    let user = User::update(&pool, id, input)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    
    Ok(Json(user))
}

pub async fn delete_user(
    State(pool): State<DbPool>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode> {
    User::delete(&pool, id)
        .await
        .map_err(|e| ApiError::Internal(e.to_string()))?;
    
    Ok(StatusCode::NO_CONTENT)
}

Route Organization

Organize routes with method routing:

// src/routes/mod.rs
use axum::{
    routing::{get, post, put, delete, get_service},
    Router,
};
use tower_http::services::ServeDir;
use crate::{db::DbPool, handlers};

pub fn create_router(pool: DbPool) -> Router {
    // API routes
    let api_routes = Router::new()
        .route("/health", get(handlers::health::health))
        .route("/users", get(handlers::user::list_users).post(handlers::user::create_user))
        .route(
            "/users/{id}",
            get(handlers::user::get_user)
                .put(handlers::user::update_user)
                .delete(handlers::user::delete_user),
        )
        .with_state(pool);
    
    Router::new().nest("/api/v1", api_routes)
}

Middleware

Custom middleware for logging, auth, and more:

// src/middleware/mod.rs
use axum::{
    body::Body,
    http::{Request, Response},
    middleware::Next,
};
use std::time::Instant;

pub async fn logging_middleware(
    request: Request<Body>,
    next: Next,
) -> Response<Body> {
    let method = request.method().clone();
    let uri = request.uri().clone();
    let start = Instant::now();
    
    let response = next.run(request).await;
    
    let elapsed = start.elapsed();
    let status = response.status();
    
    tracing::info!(
        method = %method,
        uri = %uri,
        status = %status.as_u16(),
        elapsed_ms = %elapsed.as_millis(),
        "Request completed"
    );
    
    response
}

// Auth middleware
use axum::{
    http::{header, StatusCode},
    response::IntoResponse,
};

pub async fn auth_middleware(
    mut request: Request<Body>,
    next: Next,
) -> Result<Response<Body>, impl IntoResponse> {
    let auth_header = request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|h| h.to_str().ok());
    
    match auth_header {
        Some(auth) if auth.starts_with("Bearer ") => {
            let token = auth.strip_prefix("Bearer ").unwrap();
            
            // Validate token (implement your JWT validation)
            if validate_token(token) {
                // Add user info to request extensions
                request.extensions_mut().insert(UserId("user-123".to_string()));
                Ok(next.run(request).await)
            } else {
                Err((StatusCode::UNAUTHORIZED, "Invalid token"))
            }
        }
        _ => Err((StatusCode::UNAUTHORIZED, "Missing authorization header")),
    }
}

#[derive(Clone)]
pub struct UserId(pub String);

fn validate_token(token: &str) -> bool {
    // Implement JWT validation
    !token.is_empty()
}

Apply middleware to routes:

// src/routes/mod.rs (updated)
use axum::middleware;

pub fn create_router(pool: DbPool) -> Router {
    let api_routes = Router::new()
        .route("/health", get(handlers::health::health))
        .route("/users", get(handlers::user::list_users).post(handlers::user::create_user))
        .route("/users/{id}", get(handlers::user::get_user).put(handlers::user::update_user).delete(handlers::user::delete_user))
        .route_layer(middleware::from_fn(middleware::logging_middleware))
        .with_state(pool);
    
    Router::new().nest("/api/v1", api_routes)
}

Testing

Comprehensive testing with tower::ServiceExt:

// tests/integration_test.rs
use api_server::{create_router, db::create_pool};
use axum::{
    body::Body,
    http::{Request, StatusCode},
};
use serde_json::{json, Value};
use tower::ServiceExt;

#[tokio::test]
async fn test_health_endpoint() {
    let app = create_router(create_test_pool().await);
    
    let response = app
        .oneshot(Request::builder().uri("/api/v1/health").body(Body::empty()).unwrap())
        .await
        .unwrap();
    
    assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn test_create_user() {
    let app = create_router(create_test_pool().await);
    
    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/users")
                .header("content-type", "application/json")
                .body(Body::from(
                    json!({
                        "name": "Test User",
                        "email": "test@example.com"
                    })
                    .to_string(),
                ))
                .unwrap(),
        )
        .await
        .unwrap();
    
    assert_eq!(response.status(), StatusCode::CREATED);
    
    let body = axum::body::to_bytes(response.into_body(), 1000000)
        .await
        .unwrap();
    let user: Value = serde_json::from_slice(&body).unwrap();
    
    assert_eq!(user["name"], "Test User");
    assert_eq!(user["email"], "test@example.com");
}

async fn create_test_pool() -> sqlx::PgPool {
    let database_url = std::env::var("TEST_DATABASE_URL")
        .expect("TEST_DATABASE_URL must be set");
    
    create_pool(&database_url).await.unwrap()
}

Run tests:

# Run tests
cargo test

# Run with logging
RUST_LOG=debug cargo test -- --nocapture

Configuration

Environment-based configuration:

// src/config.rs
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub struct Config {
    #[serde(default = "default_port")]
    pub port: u16,
    pub database_url: String,
    #[serde(default = "default_log_level")]
    pub log_level: String,
}

fn default_port() -> u16 { 3000 }
fn default_log_level() -> String { "info".to_string() }

impl Config {
    pub fn from_env() -> Result<Self, config::ConfigError> {
        config::Config::builder()
            .add_source(config::Environment::default())
            .build()?
            .try_deserialize()
    }
}

Performance Benchmarks

Rust with Axum delivers impressive performance:

// benches/api_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use api_server::create_router;
use axum::http::Request;

fn bench_list_users(c: &mut Criterion) {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let pool = rt.block_on(create_pool("postgres://localhost/test"));
    let app = create_router(pool);
    
    c.bench_function("list_users", |b| {
        b.to_async(&rt).iter(|| {
            let app = app.clone();
            async move {
                app.oneshot(
                    Request::builder()
                        .uri("/api/v1/users")
                        .body(Body::empty())
                        .unwrap(),
                )
                .await
                .unwrap()
            }
        })
    });
}

criterion_group!(benches, bench_list_users);
criterion_main!(benches);

Typical results on my machine (M3 Pro):

  • Simple GET: ~2μs
  • JSON serialization: ~5μs
  • Database query: ~50-100μs

Production Deployment

Dockerfile

# Build stage
FROM rust:1.78-alpine AS builder
RUN apk add --no-cache musl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release

# Runtime stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/target/release/api-server /usr/local/bin/
EXPOSE 3000
CMD ["api-server"]

Kubernetes Deployment

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      containers:
      - name: api-server
        image: api-server:latest
        ports:
        - containerPort: 3000
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: api-secrets
              key: database-url
        resources:
          requests:
            memory: "64Mi"
            cpu: "100m"
          limits:
            memory: "256Mi"
            cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
  name: api-server
spec:
  selector:
    app: api-server
  ports:
  - port: 80
    targetPort: 3000

Key Takeaways

  1. Leverage the type system — Rust’s types catch errors before they reach production
  2. Use SQLx for compile-time queries — Database queries are validated at compile time
  3. Implement proper error handlingIntoResponse trait for clean error responses
  4. Middleware for cross-cutting concerns — Logging, auth, metrics in one place
  5. Test everything — Rust’s test ecosystem is excellent for integration tests
  6. Benchmark performance — Rust delivers consistent, predictable performance
  7. Deploy with containers — Static binaries make deployment simple
  8. Monitor in production — Tracing and metrics for observability

Rust with Axum gives you the performance of C++ with the safety of a modern language. The initial learning curve is steep, but the payoff is substantial: APIs that are fast, safe, and maintainable at scale.

I have been migrating performance-critical services to Rust, similar to my work on the ERP platform. The consistency and reliability gains are worth the investment.

The Rust compiler is strict, but it is also your best team member — catching bugs before they become incidents.

You might also like

Enjoyed This Post?

Want to discuss the topic, have questions, or looking to collaborate on something similar? Drop a comment below or reach out directly.

Discussion