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

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:
| Feature | Axum | Actix Web | Rocket |
|---|---|---|---|
| Backed by | Tokio team | Community | Community |
| Middleware | Tower (shared ecosystem) | Custom | Custom |
| Macros required | No | Minimal | Heavy |
| Async runtime | Tokio | Actix/Tokio | Tokio |
| Learning curve | Moderate | Moderate | Lower |
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-apiOpen 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/bookmarksNever 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-alpineInstall the SQLx CLI for managing migrations:
cargo install sqlx-cli --no-default-features --features postgresCreate your first migration:
sqlx migrate add create_bookmarksEdit 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 runStep 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.rsStep 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:
FromRowlets SQLx map database rows directly to structsSerialize/Deserializehandle JSON conversionCreateBookmarkandUpdateBookmarkare 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) = ¶ms.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) = ¶ms.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 poolPath(id)→ URL path parametersQuery(params)→ query string parametersJson(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 runYou 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_HEREStep 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 testTroubleshooting
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:
| Framework | Requests/sec | Latency (p99) | Memory |
|---|---|---|---|
| Axum (Rust) | ~180,000 | 1.2ms | 8MB |
| Express (Node) | ~35,000 | 8ms | 65MB |
| FastAPI (Python) | ~12,000 | 15ms | 45MB |
| Spring Boot (Java) | ~45,000 | 5ms | 200MB |
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
jsonwebtokenand Tower layers - Database connection pooling — Tune
PgPoolOptionsfor production workloads - OpenAPI docs — Use
utoipato 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.
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

Building Production-Ready REST APIs with FastAPI, PostgreSQL, and Docker
Learn how to build, test, and deploy a production-grade REST API using Python's FastAPI framework with PostgreSQL, SQLAlchemy, Alembic migrations, and Docker Compose — from zero to deployment.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.

Building AI Agents from Scratch with TypeScript: Master the ReAct Pattern Using the Vercel AI SDK
Learn how to build AI agents from the ground up using TypeScript. This tutorial covers the ReAct pattern, tool calling, multi-step reasoning, and production-ready agent loops with the Vercel AI SDK.