DEV Community

Syeed Talha
Syeed Talha

Posted on

Build In-Memory CRUD API in Axum (Rust)

If you're learning Rust and want to build APIs, Axum is one of the best frameworks to start with.

In this tutorial, we'll build a simple CRUD API (Create, Read, Update, Delete) using:

  • ✅ Axum
  • ✅ In-memory storage (no database)
  • ✅ Beginner-friendly concepts

What We Will Build

A simple User API with these endpoints:

Method Route Description
POST /users Create a user
GET /users Get all users
GET /user/:id Get a single user
PUT /user/:id Update a user
DELETE /user/:id Delete a user

Step 1: Setup Dependencies

Add this to your Cargo.toml:

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

Step 2: Define Our Data

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

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}
Enter fullscreen mode Exit fullscreen mode
  • User → the stored data structure
  • CreateUser → the incoming request body

Step 3: App State (In-Memory Database)

type AppState = Arc<Mutex<Vec<User>>>;
Enter fullscreen mode Exit fullscreen mode

We use:

  • Vec<User> → to store users
  • Mutex → for safe concurrent access
  • Arc → to share state across threads

Step 4: Create User (POST)

async fn create_user(
    State(state): State<AppState>,
    Json(body): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
    let mut users = state.lock().unwrap();
    let new_id = users.last().map_or(1, |u| u.id + 1);
    let new_user = User {
        id: new_id,
        name: body.name,
        email: body.email,
    };
    users.push(new_user.clone());
    (StatusCode::CREATED, Json(new_user))
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Get All Users (GET)

async fn user_list(State(state): State<AppState>) -> Json<Vec<User>> {
    let users = state.lock().unwrap();
    Json(users.clone())
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Get Single User

async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<User>, StatusCode> {
    let users = state.lock().unwrap();

    if let Some(user) = users.iter().find(|u| u.id == id) {
        Ok(Json(user.clone()))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Update User

async fn update_user(
    State(state): State<AppState>,
    Path(id): Path<u32>,
    Json(body): Json<CreateUser>,
) -> Result<Json<User>, StatusCode> {
    let mut users = state.lock().unwrap();

    if let Some(user) = users.iter_mut().find(|u| u.id == id) {
        user.name = body.name;
        user.email = body.email;
        Ok(Json(user.clone()))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Delete User

async fn delete_user(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> StatusCode {
    let mut users = state.lock().unwrap();

    if let Some(pos) = users.iter().position(|u| u.id == id) {
        users.remove(pos);
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 9: Setup Router & Main

#[tokio::main]
async fn main() {
    let state: AppState = Arc::new(Mutex::new(vec![]));

    let app = Router::new()
        .route("/users", get(user_list).post(create_user))
        .route(
            "/user/:id",
            get(get_user)
                .put(update_user)
                .delete(delete_user),
        )
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    println!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Full Code (Mutex Version)

use axum::{
    extract::{State, Path},
    http::StatusCode,
    routing::{get, post, put, delete},
    Json,
    Router,
};

use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

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

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

type AppState = Arc<Mutex<Vec<User>>>;

async fn create_user(
    State(state): State<AppState>,
    Json(body): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
    let mut users = state.lock().unwrap();
    let new_user = User {
        id: users.len() as u32 + 1,
        name: body.name,
        email: body.email,
    };
    users.push(new_user.clone());
    (StatusCode::CREATED, Json(new_user))
}

async fn user_list(State(state): State<AppState>) -> Json<Vec<User>> {
    let users = state.lock().unwrap();
    Json(users.clone())
}

async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<User>, StatusCode> {
    let users = state.lock().unwrap();
    if let Some(user) = users.iter().find(|u| u.id == id) {
        Ok(Json(user.clone()))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

async fn update_user(
    State(state): State<AppState>,
    Path(id): Path<u32>,
    Json(body): Json<CreateUser>,
) -> Result<Json<User>, StatusCode> {
    let mut users = state.lock().unwrap();
    if let Some(user) = users.iter_mut().find(|u| u.id == id) {
        user.name = body.name;
        user.email = body.email;
        Ok(Json(user.clone()))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

async fn delete_user(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> StatusCode {
    let mut users = state.lock().unwrap();
    if let Some(pos) = users.iter().position(|u| u.id == id) {
        users.remove(pos);
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

#[tokio::main]
async fn main() {
    let state: AppState = Arc::new(Mutex::new(vec![]));

    let app = Router::new()
        .route("/users", post(create_user))
        .route("/users", get(user_list))
        .route("/user/:id", put(update_user))
        .route("/user/:id", delete(delete_user))
        .route("/user/:id", get(get_user))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Bonus: Async RwLock Version

For better read performance (multiple concurrent reads), use tokio::sync::RwLock instead of Mutex:

use axum::{
    extract::State,
    http::StatusCode,
    routing::{get, post, put, delete},
    extract::{Path, Json},
    Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;

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

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

type AppState = Arc<RwLock<Vec<User>>>;

async fn create_user(
    State(state): State<AppState>,
    Json(body): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
    let mut users = state.write().await;
    let new_id = users.last().map_or(1, |u| u.id + 1);
    let new_user = User {
        id: new_id,
        name: body.name,
        email: body.email,
    };
    users.push(new_user.clone());
    (StatusCode::CREATED, Json(new_user))
}

async fn user_list(State(state): State<AppState>) -> Json<Vec<User>> {
    let users = state.read().await;
    Json(users.clone())
}

async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> Result<Json<User>, StatusCode> {
    let users = state.read().await;
    if let Some(user) = users.iter().find(|u| u.id == id) {
        Ok(Json(user.clone()))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

async fn delete_user(
    State(state): State<AppState>,
    Path(id): Path<u32>,
) -> StatusCode {
    let mut users = state.write().await;
    if let Some(pos) = users.iter().position(|u| u.id == id) {
        users.remove(pos);
        StatusCode::NO_CONTENT
    } else {
        StatusCode::NOT_FOUND
    }
}

async fn update_user(
    State(state): State<AppState>,
    Path(id): Path<u32>,
    Json(body): Json<User>,
) -> Result<Json<User>, StatusCode> {
    let mut users = state.write().await;
    if let Some(user) = users.iter_mut().find(|u| u.id == id) {
        user.name = body.name;
        user.email = body.email;
        Ok(Json(user.clone()))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

#[tokio::main]
async fn main() {
    let state: AppState = Arc::new(RwLock::new(vec![]));

    let app = Router::new()
        .route("/users", get(user_list))
        .route("/users", post(create_user))
        .route("/user/{id}", get(get_user))
        .route("/user/{id}", delete(delete_user))
        .route("/user/{id}", put(update_user))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}
Enter fullscreen mode Exit fullscreen mode

Mutex vs RwLock: Use Mutex for simplicity. Use RwLock when you have many reads and few writes — it allows multiple readers simultaneously.


How to Test

Use Postman or curl to test your endpoints.

Create a user:

POST /users
Content-Type: application/json

{
  "name": "John",
  "email": "john@example.com"
}
Enter fullscreen mode Exit fullscreen mode

Get all users:

GET /users
Enter fullscreen mode Exit fullscreen mode

Get a single user:

GET /user/1
Enter fullscreen mode Exit fullscreen mode

Update a user:

PUT /user/1
Content-Type: application/json

{
  "name": "John Updated",
  "email": "john_updated@example.com"
}
Enter fullscreen mode Exit fullscreen mode

Delete a user:

DELETE /user/1
Enter fullscreen mode Exit fullscreen mode

Key Concepts You Learned

  • Axum routing — how to define and chain HTTP routes
  • State management — sharing data across handlers with Arc<Mutex<T>>
  • JSON handling — extracting request bodies and returning JSON responses
  • Path parameters — reading dynamic values from the URL
  • CRUD operations — implementing all four basic data operations

What's Next?

Now that you have the basics down, you can level up by:

  • Replacing in-memory storage with PostgreSQL (using sqlx)
  • Adding input validation (using validator)
  • Adding authentication (JWT tokens)
  • Switching to tokio::sync::RwLock for better concurrency

Happy coding!

Top comments (0)