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
- Leverage the type system — Rust’s types catch errors before they reach production
- Use SQLx for compile-time queries — Database queries are validated at compile time
- Implement proper error handling —
IntoResponsetrait for clean error responses - Middleware for cross-cutting concerns — Logging, auth, metrics in one place
- Test everything — Rust’s test ecosystem is excellent for integration tests
- Benchmark performance — Rust delivers consistent, predictable performance
- Deploy with containers — Static binaries make deployment simple
- 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
Rate Limiting Strategies for APIs: Protect Your Backend in 2026
Master API rate limiting with practical strategies and implementations. Compare token bucket, sliding window, and fixed window algorithms with real code examples in Go, Node.js, and Redis.
Database Connection Pooling: Patterns for High-Performance Applications
Master database connection pooling for scalable applications. Compare pgBouncer, HikariCP, and connection pool patterns with practical examples and performance benchmarks.
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
Learn how prompt caching can slash your LLM API costs by up to 90%. Compare Anthropic, OpenAI, and Google's caching strategies with practical implementation examples.
More Posts
API Gateway Patterns: The Front Door to Your Microservices
Web Components 2026: Building Framework-Agnostic UI Libraries
Building Autonomous AI Workflows with LangGraph: A Practical Guide
Building Type-Safe APIs with tRPC in 2026: Full-Stack TypeScript Without Schemas
Database Connection Pooling: Patterns for High-Performance Applications
Prompt Caching: Reduce LLM Costs by 90% with Smart Context Management
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.
