Building REST APIs with Rust and Axum: A Practical Beginner's Guide

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Rust is the most loved programming language 8 years running — and now it's ready for web development. With Axum, the Tokio-backed web framework, you get memory safety, fearless concurrency, and blazing performance without a garbage collector. In this tutorial, you'll build a complete REST API from scratch.

What You'll Build

A fully functional Bookmark Manager API with:

  • CRUD operations for bookmarks (create, read, update, delete)
  • JSON request/response handling with Serde
  • PostgreSQL database with SQLx (compile-time checked queries)
  • Structured error handling with proper HTTP status codes
  • Shared application state
  • Request validation
  • Integration tests

Prerequisites

Before starting, make sure you have:

  • Rust 1.75+ installed via rustup.rs
  • PostgreSQL 15+ running locally or via Docker
  • Basic understanding of HTTP and REST concepts
  • A code editor (VS Code with rust-analyzer recommended)
  • A terminal

New to Rust? You should be comfortable with ownership, structs, enums, and basic error handling. The official Rust Book chapters 1–10 are sufficient.


Why Axum?

Before diving in, let's understand why Axum stands out in the Rust ecosystem:

FeatureAxumActix WebRocket
Backed byTokio teamCommunityCommunity
MiddlewareTower (shared ecosystem)CustomCustom
Macros requiredNoMinimalHeavy
Async runtimeTokioActix/TokioTokio
Learning curveModerateModerateLower

Axum's key advantage is its deep integration with the Tower middleware ecosystem. Any Tower-compatible middleware works with Axum out of the box — rate limiting, tracing, compression, authentication, and more.


Step 1: Project Setup

Create a new Rust project:

cargo new bookmark-api && cd bookmark-api

Open Cargo.toml and add your dependencies:

[package]
name = "bookmark-api"
version = "0.1.0"
edition = "2021"
 
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
dotenvy = "0.15"
thiserror = "2"

Create a .env file for your database configuration:

DATABASE_URL=postgres://postgres:password@localhost:5432/bookmarks

Never commit .env files to version control. Add it to .gitignore immediately.


Step 2: Database Setup

Start PostgreSQL (using Docker if you prefer):

docker run -d \
  --name bookmarks-db \
  -e POSTGRES_PASSWORD=password \
  -e POSTGRES_DB=bookmarks \
  -p 5432:5432 \
  postgres:16-alpine

Install the SQLx CLI for managing migrations:

cargo install sqlx-cli --no-default-features --features postgres

Create your first migration:

sqlx migrate add create_bookmarks

Edit the generated migration file in migrations/:

-- migrations/YYYYMMDD_create_bookmarks.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
 
CREATE TABLE bookmarks (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    title VARCHAR(255) NOT NULL,
    url TEXT NOT NULL,
    description TEXT,
    tags TEXT[] DEFAULT '{}',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_bookmarks_tags ON bookmarks USING GIN (tags);
CREATE INDEX idx_bookmarks_created_at ON bookmarks (created_at DESC);

Run the migration:

sqlx migrate run

Step 3: Application Structure

Organize your project into modules. Create the following file structure:

bookmark-api/
├── Cargo.toml
├── .env
├── migrations/
│   └── YYYYMMDD_create_bookmarks.sql
└── src/
    ├── main.rs
    ├── config.rs
    ├── db.rs
    ├── error.rs
    ├── models.rs
    ├── handlers.rs
    └── routes.rs

Create each file:

touch src/config.rs src/db.rs src/error.rs src/models.rs src/handlers.rs src/routes.rs

Step 4: Configuration Module

Start with src/config.rs — a clean way to load environment variables:

// src/config.rs
use std::env;
 
pub struct Config {
    pub database_url: String,
    pub host: String,
    pub port: u16,
}
 
impl Config {
    pub fn from_env() -> Self {
        dotenvy::dotenv().ok();
 
        Self {
            database_url: env::var("DATABASE_URL")
                .expect("DATABASE_URL must be set"),
            host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
            port: env::var("PORT")
                .unwrap_or_else(|_| "3000".to_string())
                .parse()
                .expect("PORT must be a number"),
        }
    }
}

Step 5: Database Connection

Create the database connection pool in src/db.rs:

// src/db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
 
pub async fn create_pool(database_url: &str) -> PgPool {
    PgPoolOptions::new()
        .max_connections(10)
        .connect(database_url)
        .await
        .expect("Failed to create database pool")
}

Step 6: Data Models

Define your models in src/models.rs. Axum uses Serde for JSON serialization:

// src/models.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
 
#[derive(Debug, Serialize, FromRow)]
pub struct Bookmark {
    pub id: Uuid,
    pub title: String,
    pub url: String,
    pub description: Option<String>,
    pub tags: Vec<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}
 
#[derive(Debug, Deserialize)]
pub struct CreateBookmark {
    pub title: String,
    pub url: String,
    pub description: Option<String>,
    #[serde(default)]
    pub tags: Vec<String>,
}
 
#[derive(Debug, Deserialize)]
pub struct UpdateBookmark {
    pub title: Option<String>,
    pub url: Option<String>,
    pub description: Option<String>,
    pub tags: Option<Vec<String>>,
}
 
#[derive(Debug, Deserialize)]
pub struct BookmarkQuery {
    pub tag: Option<String>,
    pub search: Option<String>,
    #[serde(default = "default_limit")]
    pub limit: i64,
    #[serde(default)]
    pub offset: i64,
}
 
fn default_limit() -> i64 {
    20
}

Key points:

  • FromRow lets SQLx map database rows directly to structs
  • Serialize / Deserialize handle JSON conversion
  • CreateBookmark and UpdateBookmark are separate types — Rust's type system enforces valid requests at compile time

Step 7: Error Handling

Proper error handling is crucial. Create src/error.rs:

// src/error.rs
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
 
#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("Resource not found")]
    NotFound,
 
    #[error("Validation error: {0}")]
    Validation(String),
 
    #[error("Database error: {0}")]
    Database(#[from] sqlx::Error),
 
    #[error("Internal server error")]
    Internal(String),
}
 
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
            AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
            AppError::Database(err) => {
                tracing::error!("Database error: {:?}", err);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Internal server error".to_string(),
                )
            }
            AppError::Internal(msg) => {
                tracing::error!("Internal error: {}", msg);
                (
                    StatusCode::INTERNAL_SERVER_ERROR,
                    "Internal server error".to_string(),
                )
            }
        };
 
        let body = Json(json!({
            "error": {
                "status": status.as_u16(),
                "message": message,
            }
        }));
 
        (status, body).into_response()
    }
}

This pattern is idiomatic Rust — the ? operator will automatically convert sqlx::Error into AppError::Database thanks to the #[from] attribute. No more scattered error handling!


Step 8: Request Handlers

Now for the core logic. Create src/handlers.rs:

// src/handlers.rs
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use sqlx::PgPool;
use uuid::Uuid;
 
use crate::error::AppError;
use crate::models::{Bookmark, BookmarkQuery, CreateBookmark, UpdateBookmark};
 
// GET /api/bookmarks
pub async fn list_bookmarks(
    State(pool): State<PgPool>,
    Query(params): Query<BookmarkQuery>,
) -> Result<Json<Vec<Bookmark>>, AppError> {
    let bookmarks = if let Some(tag) = &params.tag {
        sqlx::query_as::<_, Bookmark>(
            "SELECT * FROM bookmarks WHERE $1 = ANY(tags) ORDER BY created_at DESC LIMIT $2 OFFSET $3"
        )
        .bind(tag)
        .bind(params.limit)
        .bind(params.offset)
        .fetch_all(&pool)
        .await?
    } else if let Some(search) = &params.search {
        let pattern = format!("%{}%", search);
        sqlx::query_as::<_, Bookmark>(
            "SELECT * FROM bookmarks WHERE title ILIKE $1 OR description ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
        )
        .bind(&pattern)
        .bind(params.limit)
        .bind(params.offset)
        .fetch_all(&pool)
        .await?
    } else {
        sqlx::query_as::<_, Bookmark>(
            "SELECT * FROM bookmarks ORDER BY created_at DESC LIMIT $1 OFFSET $2"
        )
        .bind(params.limit)
        .bind(params.offset)
        .fetch_all(&pool)
        .await?
    };
 
    Ok(Json(bookmarks))
}
 
// GET /api/bookmarks/:id
pub async fn get_bookmark(
    State(pool): State<PgPool>,
    Path(id): Path<Uuid>,
) -> Result<Json<Bookmark>, AppError> {
    let bookmark = sqlx::query_as::<_, Bookmark>(
        "SELECT * FROM bookmarks WHERE id = $1"
    )
    .bind(id)
    .fetch_optional(&pool)
    .await?
    .ok_or(AppError::NotFound)?;
 
    Ok(Json(bookmark))
}
 
// POST /api/bookmarks
pub async fn create_bookmark(
    State(pool): State<PgPool>,
    Json(input): Json<CreateBookmark>,
) -> Result<(StatusCode, Json<Bookmark>), AppError> {
    if input.title.trim().is_empty() {
        return Err(AppError::Validation("Title cannot be empty".to_string()));
    }
 
    if input.url.trim().is_empty() {
        return Err(AppError::Validation("URL cannot be empty".to_string()));
    }
 
    let bookmark = sqlx::query_as::<_, Bookmark>(
        r#"
        INSERT INTO bookmarks (title, url, description, tags)
        VALUES ($1, $2, $3, $4)
        RETURNING *
        "#,
    )
    .bind(&input.title)
    .bind(&input.url)
    .bind(&input.description)
    .bind(&input.tags)
    .fetch_one(&pool)
    .await?;
 
    Ok((StatusCode::CREATED, Json(bookmark)))
}
 
// PUT /api/bookmarks/:id
pub async fn update_bookmark(
    State(pool): State<PgPool>,
    Path(id): Path<Uuid>,
    Json(input): Json<UpdateBookmark>,
) -> Result<Json<Bookmark>, AppError> {
    let bookmark = sqlx::query_as::<_, Bookmark>(
        r#"
        UPDATE bookmarks
        SET
            title = COALESCE($1, title),
            url = COALESCE($2, url),
            description = COALESCE($3, description),
            tags = COALESCE($4, tags),
            updated_at = NOW()
        WHERE id = $5
        RETURNING *
        "#,
    )
    .bind(&input.title)
    .bind(&input.url)
    .bind(&input.description)
    .bind(&input.tags)
    .bind(id)
    .fetch_optional(&pool)
    .await?
    .ok_or(AppError::NotFound)?;
 
    Ok(Json(bookmark))
}
 
// DELETE /api/bookmarks/:id
pub async fn delete_bookmark(
    State(pool): State<PgPool>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
    let result = sqlx::query("DELETE FROM bookmarks WHERE id = $1")
        .bind(id)
        .execute(&pool)
        .await?;
 
    if result.rows_affected() == 0 {
        return Err(AppError::NotFound);
    }
 
    Ok(StatusCode::NO_CONTENT)
}

Notice the extractor pattern — Axum extracts data from the request automatically:

  • State(pool) → shared database pool
  • Path(id) → URL path parameters
  • Query(params) → query string parameters
  • Json(input) → JSON request body

Each handler returns Result<T, AppError> where T implements IntoResponse. When an error occurs, the ? operator propagates it cleanly.


Step 9: Routing

Connect everything in src/routes.rs:

// src/routes.rs
use axum::routing::{delete, get, post, put};
use axum::Router;
use sqlx::PgPool;
 
use crate::handlers;
 
pub fn create_router(pool: PgPool) -> Router {
    Router::new()
        .route("/api/bookmarks", get(handlers::list_bookmarks))
        .route("/api/bookmarks", post(handlers::create_bookmark))
        .route("/api/bookmarks/{id}", get(handlers::get_bookmark))
        .route("/api/bookmarks/{id}", put(handlers::update_bookmark))
        .route("/api/bookmarks/{id}", delete(handlers::delete_bookmark))
        .route("/health", get(health_check))
        .with_state(pool)
}
 
async fn health_check() -> &'static str {
    "OK"
}

Axum 0.8 uses {param} syntax for path parameters instead of the older :param syntax.


Step 10: Main Entry Point

Tie everything together in src/main.rs:

// src/main.rs
mod config;
mod db;
mod error;
mod handlers;
mod models;
mod routes;
 
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber;
 
#[tokio::main]
async fn main() {
    // Initialize tracing
    tracing_subscriber::fmt()
        .with_target(false)
        .compact()
        .init();
 
    // Load configuration
    let config = config::Config::from_env();
 
    // Create database pool
    let pool = db::create_pool(&config.database_url).await;
 
    // Run pending migrations
    sqlx::migrate!()
        .run(&pool)
        .await
        .expect("Failed to run migrations");
 
    tracing::info!("Migrations applied successfully");
 
    // Build router with middleware
    let app = routes::create_router(pool)
        .layer(TraceLayer::new_for_http())
        .layer(CorsLayer::permissive());
 
    // Start server
    let addr = format!("{}:{}", config.host, config.port);
    tracing::info!("Server starting on {}", addr);
 
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Step 11: Build and Run

Let's test everything:

# Make sure PostgreSQL is running
cargo run

You should see:

Server starting on 0.0.0.0:3000

Test with curl:

# Health check
curl http://localhost:3000/health
 
# Create a bookmark
curl -X POST http://localhost:3000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Rust Book",
    "url": "https://doc.rust-lang.org/book/",
    "description": "The official Rust programming language book",
    "tags": ["rust", "learning", "documentation"]
  }'
 
# List all bookmarks
curl http://localhost:3000/api/bookmarks
 
# Filter by tag
curl "http://localhost:3000/api/bookmarks?tag=rust"
 
# Search by title
curl "http://localhost:3000/api/bookmarks?search=rust"
 
# Get a specific bookmark (replace UUID)
curl http://localhost:3000/api/bookmarks/YOUR_UUID_HERE
 
# Update a bookmark
curl -X PUT http://localhost:3000/api/bookmarks/YOUR_UUID_HERE \
  -H "Content-Type: application/json" \
  -d '{"title": "The Rust Programming Language Book"}'
 
# Delete a bookmark
curl -X DELETE http://localhost:3000/api/bookmarks/YOUR_UUID_HERE

Step 12: Adding Middleware

Axum shines with Tower middleware. Let's add request logging and rate limiting. Update Cargo.toml:

[dependencies]
# ... existing deps ...
tower = { version = "0.5", features = ["limit", "timeout"] }

Add a request timing middleware in src/main.rs:

use axum::middleware::{self, Next};
use axum::extract::Request;
use axum::response::Response;
use std::time::Instant;
 
async fn track_request_time(request: Request, next: Next) -> Response {
    let method = request.method().clone();
    let uri = request.uri().clone();
    let start = Instant::now();
 
    let response = next.run(request).await;
 
    let duration = start.elapsed();
    tracing::info!(
        "{} {} → {} ({}ms)",
        method,
        uri,
        response.status(),
        duration.as_millis()
    );
 
    response
}

Then add it to your router:

let app = routes::create_router(pool)
    .layer(middleware::from_fn(track_request_time))
    .layer(TraceLayer::new_for_http())
    .layer(CorsLayer::permissive());

Step 13: Integration Tests

Create tests/api_tests.rs:

// tests/api_tests.rs
use axum::http::StatusCode;
use axum_test::TestServer;
use serde_json::json;
 
async fn setup() -> TestServer {
    dotenvy::dotenv().ok();
    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");
 
    let pool = sqlx::postgres::PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await
        .unwrap();
 
    sqlx::migrate!().run(&pool).await.unwrap();
 
    let app = bookmark_api::routes::create_router(pool);
    TestServer::new(app).unwrap()
}
 
#[tokio::test]
async fn test_health_check() {
    let server = setup().await;
    let response = server.get("/health").await;
    assert_eq!(response.status_code(), StatusCode::OK);
    assert_eq!(response.text(), "OK");
}
 
#[tokio::test]
async fn test_create_and_get_bookmark() {
    let server = setup().await;
 
    // Create
    let response = server
        .post("/api/bookmarks")
        .json(&json!({
            "title": "Test Bookmark",
            "url": "https://example.com",
            "tags": ["test"]
        }))
        .await;
 
    assert_eq!(response.status_code(), StatusCode::CREATED);
 
    let bookmark: serde_json::Value = response.json();
    let id = bookmark["id"].as_str().unwrap();
 
    // Get
    let response = server.get(&format!("/api/bookmarks/{}", id)).await;
    assert_eq!(response.status_code(), StatusCode::OK);
 
    let fetched: serde_json::Value = response.json();
    assert_eq!(fetched["title"], "Test Bookmark");
}
 
#[tokio::test]
async fn test_not_found() {
    let server = setup().await;
    let response = server
        .get("/api/bookmarks/00000000-0000-0000-0000-000000000000")
        .await;
    assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
}

Add the test dependency to Cargo.toml:

[dev-dependencies]
axum-test = "16"

And make your modules public in src/main.rs so tests can access them:

pub mod config;
pub mod db;
pub mod error;
pub mod handlers;
pub mod models;
pub mod routes;

Run the tests:

cargo test

Troubleshooting

Common Issues

"error: no database found" — SQLx checks queries at compile time. Run cargo sqlx prepare to create offline query data, or set SQLX_OFFLINE=true for CI environments.

Connection refused — Make sure PostgreSQL is running and DATABASE_URL is correct. Test with psql $DATABASE_URL.

Slow compilation — Rust compile times are real. Use cargo watch -x run (install with cargo install cargo-watch) for automatic recompilation during development.

Type mismatch on Vec<String> — PostgreSQL TEXT[] maps to Vec<String> in SQLx. Make sure the column type is TEXT[], not VARCHAR[].


Performance Comparison

Axum's performance is exceptional. In typical benchmarks:

FrameworkRequests/secLatency (p99)Memory
Axum (Rust)~180,0001.2ms8MB
Express (Node)~35,0008ms65MB
FastAPI (Python)~12,00015ms45MB
Spring Boot (Java)~45,0005ms200MB

These are approximate numbers from community benchmarks. Your actual results depend on your hardware and workload.


Next Steps

You now have a solid foundation for building APIs in Rust with Axum. Here are some directions to explore:

  • Authentication — Add JWT middleware using jsonwebtoken and Tower layers
  • Database connection pooling — Tune PgPoolOptions for production workloads
  • OpenAPI docs — Use utoipa to generate Swagger documentation
  • Docker deployment — Create a multi-stage Dockerfile for minimal images (~10MB)
  • Rate limiting — Use tower::limit::RateLimitLayer
  • WebSocket support — Axum has built-in WebSocket support for real-time features

Conclusion

In this tutorial, you built a complete REST API with Rust and Axum featuring:

  • Clean project structure with separate modules
  • Type-safe database queries with SQLx
  • Proper error handling using Rust's type system
  • Tower middleware for cross-cutting concerns
  • Integration tests

Rust's learning curve is steeper than other languages, but the payoff is enormous — memory safety without a garbage collector, fearless concurrency, and performance that rivals C/C++. Axum makes web development in Rust approachable while leveraging the full power of the Tokio ecosystem.

The Rust web ecosystem is maturing rapidly. Libraries like Axum, SQLx, and Tower are production-ready and used by companies like Cloudflare, Discord, and Figma. If you're building APIs where performance and reliability matter, Rust deserves a serious look.


Want to read more tutorials? Check out our latest tutorial on 11 Laravel 11 Basics: URL Generation.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles