Axum + PostgreSQL: Building a High-Performance REST API in Rust
A comprehensive, production-grade guide to building a modular, asynchronous REST API web service using Axum, SQLx, and PostgreSQL in Rust, complete with connection pooling, CORS middleware, validation schemas, and database migrations.
1. Why Axum? (The Status Quo & Problem Statement)
When building web servers in Rust, developers often look at frameworks like Actix-web. While Actix-web is exceptionally fast and feature-rich, it requires a steep learning curve and total commitment to its proprietary feature set.
For developers looking for something lightweight, modular, and idiomatic, Axum emerges as an exceptional alternative:
- Built on Proven Ecosystem Giants: Axum is a relatively thin layer built on top of Hyper (an HTTP client/server library), Tower (middleware abstraction layer), and Tokio (asynchronous runtime).
- Lightweight & Focused: It doesn't burden the developer with an overwhelming array of built-in features, keeping the API surface minimal and documentation straightforward.
- Easy End-to-End Composition: It integrates seamlessly with
sqlx,serde, and standard asynchronous patterns without forcing custom abstractions.
2. Core Mental Model & Architecture
Axum approaches routing and request handling through composable layers and shared state. Requests flow from the transport layer down to specific handlers via routers, utilizing asynchronous connection pools shared safely across threads.
3. Project Structure & Modular Layout
To maintain clean separation of concerns and avoid monolithic codebases, the project is structured into modular files within the src/ directory:
4. Environment Configuration & Docker Setup
Before writing Rust code, we spin up our backing services using Docker Compose. This provisions both PostgreSQL and PGAdmin.
.env Configuration
POSTGRES_HOST=localhost
POSTGRES_PORT=6500
POSTGRES_USER=admin
POSTGRES_PASSWORD=password123
POSTGRES_DB=rust_sqlx
DATABASE_URL=postgres://admin:password123@localhost:6500/rust_sqlx
docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:latest
container_name: postgres
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: password123
POSTGRES_DB: rust_sqlx
ports:
- "6500:5432"
volumes:
- pgdata:/var/lib/postgresql/data
pgadmin:
image: dpage/pgadmin4
container_name: pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: admin@admin.com
PGADMIN_DEFAULT_PASSWORD: password123
ports:
- "5050:80"
volumes:
pgdata:
5. Data Models and Validation Schemas
src/model.rs: Database Entity
The model reflects the exact table schema stored in PostgreSQL, utilizing sqlx and chrono for timestamps.
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Serialize, Deserialize, FromRow, Clone, Debug)]
pub struct NoteModel {
pub id: Uuid,
pub title: String,
pub content: String,
pub category: Option<String>,
pub published: bool,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
src/schema.rs: Request Validation & Payloads
Incoming client payloads are separated from database models to enforce strict validation rules (e.g., users cannot supply id, created_at, or updated_at when creating a note).
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Debug, Default)]
pub struct FilterOptions {
pub page: Option<usize>,
pub limit: Option<usize>,
}
#[derive(Deserialize, Debug)]
pub struct ParamOptions {
pub id: String,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CreateNoteSchema {
pub title: String,
pub content: String,
pub category: Option<String>,
pub published: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateNoteSchema {
pub title: Option<String>,
pub content: Option<String>,
pub category: Option<String>,
pub published: Option<bool>,
}
6. State Management and Main Application Setup (main.rs)
The main function initializes the Tokio runtime, reads environment variables, sets up the PostgreSQL connection pool wrapped in an AppState struct, configures CORS middleware, and starts the TCP listener.
mod handler;
mod model;
mod route;
mod schema;
use axum::http::{
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
HeaderValue, Method,
};
use dotenv::dotenv;
use sqlx::{postgres::PgPoolOptions, Pool, Postgres};
use std::{env, sync::Arc};
use tower_http::cors::CorsLayer;
pub struct AppState {
pub db: Pool<Postgres>,
}
#[tokio::main]
async fn main() {
dotenv().ok();
let database_url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set in environment");
let listener_port = 8000;
// Establish PostgreSQL Connection Pool
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await
.expect("Failed to connect to PostgreSQL database");
println!("-> Connection to the database successful!");
let cors = CorsLayer::new()
.allow_origin("http://localhost:3000".parse::<HeaderValue>().unwrap())
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
.allow_credentials(true)
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);
let app_state = Arc::new(AppState { db: pool });
// Build Router
let app = route::create_router(app_state).layer(cors);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", listener_port))
.await
.unwrap();
println!("-> Server started successfully on port {}", listener_port);
axum::serve(listener, app).await.unwrap();
}
7. Routing Architecture (route.rs)
The router maps incoming HTTP methods and URI paths to their corresponding handler functions, injecting the shared AppState into the handler closures.
use axum::{
routing::{get, post},
Router,
};
use std::sync::Arc;
use crate::handler::{
create_note_handler, delete_note_handler, edit_note_handler, get_note_handler,
health_checker_handler, note_list_handler,
};
use crate::AppState;
pub fn create_router(app_state: Arc<AppState>) -> Router {
Router::new()
.route("/api/healthchecker", get(health_checker_handler))
.route("/api/notes", get(note_list_handler).post(create_note_handler))
.route(
"/api/notes/:id",
get(get_note_handler)
.patch(edit_note_handler)
.delete(delete_note_handler),
)
.with_state(app_state)
}
8. Request Handlers & Database Interaction (handler.rs)
Handlers contain the core business logic, executing raw SQL queries via sqlx and returning serialized JSON responses.
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc;
use std::sync::Arc;
use uuid::Uuid;
use crate::{
model::NoteModel,
schema::{CreateNoteSchema, FilterOptions, UpdateNoteSchema},
AppState,
};
// Health Check Handler
pub async fn health_checker_handler() -> impl IntoResponse {
const MESSAGE: &str = "Simple CRUD API with Rust, SQLx, Postgres, and Axum";
let json_response = serde_json::json!({
"status": "success",
"message": MESSAGE
});
Json(json_response)
}
// Get All Notes with Pagination
pub async fn note_list_handler(
Opts: Query<FilterOptions>,
State(data): State<Arc<AppState>>,
) -> impl IntoResponse {
let limit = opts.limit.unwrap_or(10) as i32;
let offset = ((opts.page.unwrap_or(1) - 1) * limit as usize) as i32;
let query_result = sqlx::query_as!(
NoteModel,
"SELECT id, title, content, category, published, created_at, updated_at FROM notes ORDER BY id LIMIT $1 OFFSET $2",
limit,
offset
)
.fetch_all(&data.db)
.await;
match query_result {
Ok(notes) => {
let response = serde_json::json!({
"status": "success",
"results": notes.len(),
"notes": notes
});
Json(response).into_response()
}
Err(_) => {
let error_response = serde_json::json!({
"status": "fail",
"message": "Something bad happened while fetching all note items"
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)).into_response()
}
}
}
// Create Note Handler
pub async fn create_note_handler(
State(data): State<Arc<AppState>>,
Json(body): Json<CreateNoteSchema>,
) -> impl IntoResponse {
let published = body.published.unwrap_or(false);
let query_result = sqlx::query_as!(
NoteModel,
"INSERT INTO notes (title, content, category, published) VALUES ($1, $2, $3, $4) RETURNING id, title, content, category, published, created_at, updated_at",
body.title,
body.content,
body.category,
published
)
.fetch_one(&data.db)
.await;
match query_result {
Ok(note) => {
let response = serde_json::json!({
"status": "success",
"data": { "note": note }
});
(StatusCode::CREATED, Json(response)).into_response()
}
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
let error_response = serde_json::json!({
"status": "fail",
"message": "Note with that title already exists"
});
(StatusCode::CONFLICT, Json(error_response)).into_response()
}
Err(_) => {
let error_response = serde_json::json!({
"status": "error",
"message": "Internal server error"
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)).into_response()
}
}
}
// Get Single Note by ID
pub async fn get_note_handler(
Path(id): Path<Uuid>,
State(data): State<Arc<AppState>>,
) -> impl IntoResponse {
let query_result = sqlx::query_as!(
NoteModel,
"SELECT id, title, content, category, published, created_at, updated_at FROM notes WHERE id = $1",
id
)
.fetch_one(&data.db)
.await;
match query_result {
Ok(note) => {
let response = serde_json::json!({
"status": "success",
"data": { "note": note }
});
Json(response).into_response()
}
Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("Note with ID: {} not found", id)
});
(StatusCode::NOT_FOUND, Json(error_response)).into_response()
}
Err(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response()
}
}
}
// Delete Note Handler
pub async fn delete_note_handler(
Path(id): Path<Uuid>,
State(data): State<Arc<AppState>>,
) -> impl IntoResponse {
let rows_affected = match sqlx::query!("DELETE FROM notes WHERE id = $1", id)
.execute(&data.db)
.await
{
Ok(result) => result.rows_affected(),
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
if rows_affected == 0 {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("Note with ID: {} not found", id)
});
return (StatusCode::NOT_FOUND, Json(error_response)).into_response();
}
StatusCode::NO_CONTENT.into_response()
}
// Edit Note Handler
pub async fn edit_note_handler(
Path(id): Path<Uuid>,
State(data): State<Arc<AppState>>,
Json(body): Json<UpdateNoteSchema>,
) -> impl IntoResponse {
// First, verify note existence
let note = match sqlx::query_as!(
NoteModel,
"SELECT id, title, content, category, published, created_at, updated_at FROM notes WHERE id = $1",
id
)
.fetch_one(&data.db)
.await
{
Ok(note) => note,
Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("Note with ID: {} not found", id)
});
return (StatusCode::NOT_FOUND, Json(error_response)).into_response();
}
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
};
let title = body.title.unwrap_or(note.title);
let content = body.content.unwrap_or(note.content);
let category = body.category.or(note.category);
let published = body.published.unwrap_or(note.published);
let updated_at = Utc::now();
let update_result = sqlx::query_as!(
NoteModel,
"UPDATE notes SET title = $1, content = $2, category = $3, published = $4, updated_at = $5 WHERE id = $6 RETURNING id, title, content, category, published, created_at, updated_at",
title,
content,
category,
published,
updated_at,
id
)
.fetch_one(&data.db)
.await;
match update_result {
Ok(updated_note) => {
let response = serde_json::json!({
"status": "success",
"data": { "note": updated_note }
});
Json(response).into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
9. Database Migrations & Production Realities
When running database queries via sqlx, encountering runtime errors such as relation notes does not exist is common if tables haven't been provisioned.
Migration Setup
- Access the PostgreSQL container or connect via PGAdmin (
http://localhost:5050). - Ensure the UUID extension is enabled and the table is created:
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create Notes Table
CREATE TABLE IF NOT EXISTS notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL UNIQUE,
content TEXT NOT NULL,
category VARCHAR(100),
published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
10. Summary and Key Takeaways
- Axum Framework Advantages: Built on top of Hyper, Tower, and Tokio, Axum offers a lightweight, high-performance web framework without the steep feature bloat of heavier frameworks.
- Modular Architecture: Splitting responsibilities across
main.rs,route.rs,handler.rs,model.rs, andschema.rskeeps codebases maintainable and testable. - State Sharing with
Arc<T>: Axum uses.with_state()combined with thread-safe reference counting (Arc) to share connection pools across handlers. - SQLx Compile-Time Safety: Using
sqlx::query_as!allows SQL queries to be checked against the database schema at compile time, catching typos and type mismatches early. - Request Payload Validation: Separating database models (
model.rs) from incoming request validation structures (schema.rs) prevents clients from manipulating protected audit fields like timestamps or primary keys. - CORS Configuration: Using
tower_http::cors::CorsLayerallows modern front-end applications (such as React or Next.js) running on different ports to communicate securely with the Rust backend.