Rust Web Server with Axum 0.8: In-Memory CRUD with State
October 26, 2026
Episode 3 of the Rust Backend series, where we build the backend of a blog step by step. In episode 2 our API learned JSON, but every post was made up. Today we keep real posts in memory and build all of CRUD: create, read, update and delete. We share one store between all handlers with Arc<Mutex<...>> and with_state, fix a real compile error, and answer 201, 200, 204 and 404. All the code below is the final, tested code from the video, ready to copy.
- New to Rust? Start with the beginner series.
- The full backend series playlist is here.
We continue in the same blog_api project from episodes 1 and 2 (axum 0.8, tokio, serde with the derive feature). No new crates today.
1. What CRUD means
Each action is an HTTP method on /posts:
Create -> POST /posts
Read -> GET /posts and GET /posts/{id}
Update -> PUT /posts/{id}
Delete -> DELETE /posts/{id}2. A store for the posts
A Store holds a BTreeMap from id to post, and the next id. A BTreeMap keeps its keys in order, so listing the posts always comes back sorted by id.
Every request may run on a different thread. The Mutex lets only one handler touch the store at a time, and the Arc lets all handlers share the same store:
type Db = Arc<Mutex<Store>>;In main we create an empty database with Db::default() and give it to the router with .with_state(db). A handler gets it back with the State<Db> extractor. The Json extractor reads the request body, so it must be the last argument.
get_post looks up the id. ok_or turns a missing post into 404 Not Found, and the ? returns it early.
Replace src/main.rs with:
use axum::{extract::{Path, State}, http::StatusCode, routing::{get, post}, Json, Router};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
#[derive(Serialize)]
struct Post {
id: u32,
title: String,
body: String,
}
#[derive(Deserialize)]
struct NewPost {
title: String,
body: String,
}
#[derive(Default)]
struct Store {
posts: BTreeMap<u32, Post>,
next_id: u32,
}
type Db = Arc<Mutex<Store>>;
#[tokio::main]
async fn main() {
let db = Db::default();
let app = Router::new()
.route("/posts", post(create_post))
.route("/posts/{id}", get(get_post))
.with_state(db);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
async fn create_post(State(db): State<Db>, Json(new): Json<NewPost>) -> (StatusCode, Json<Post>) {
let mut store = db.lock().unwrap();
store.next_id += 1;
let post = Post { id: store.next_id, title: new.title, body: new.body };
store.posts.insert(post.id, post.clone());
(StatusCode::CREATED, Json(post))
}
async fn get_post(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Post>, StatusCode> {
let store = db.lock().unwrap();
let post = store.posts.get(&id).ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(post.clone()))
}3. Error: no method named clone found for struct Post
cargo run fails with two errors:
error[E0599]: no method named `clone` found for struct `Post` in the current scope
--> src/main.rs:44:38
|
7 | struct Post {
| ----------- method `clone` not found for this struct
...
44 | store.posts.insert(post.id, post.clone());
| ^^^^^ method not found in `Post`
error[E0308]: mismatched types
--> src/main.rs:51:13
|
51 | Ok(Json(post.clone()))
| ---- ^^^^^^^^^^^^ expected `Post`, found `&Post`
...
note: `Post` does not implement `Clone`, so `&Post` was cloned instead
...
help: consider annotating `Post` with `#[derive(Clone)]`
|
7 + #[derive(Clone)]
8 | struct Post {
error: could not compile `blog_api` (bin "blog_api") due to 2 previous errorsThe first one says our struct cannot be copied yet. The second one even shows the fix. We add Clone to the derive:
#[derive(Serialize, Clone)]
struct Post {
id: u32,
title: String,
body: String,
}4. Test create and read with curl
Start the server with cargo run, then in a second terminal create a post. It gets id 1:
curl -i localhost:3000/posts -H 'content-type: application/json' -d '{"title":"Hello","body":"My first post"}'HTTP/1.1 201 Created
content-type: application/json
content-length: 47
{"id":1,"title":"Hello","body":"My first post"}Read it back. The server remembered it:
curl -i localhost:3000/posts/1HTTP/1.1 200 OK
content-type: application/json
content-length: 47
{"id":1,"title":"Hello","body":"My first post"}A post that does not exist:
curl -i localhost:3000/posts/9HTTP/1.1 404 Not Found
content-length: 05. List, update and delete
Stop the server (Ctrl + C) and add three handlers at the end of src/main.rs:
list_postslocks the store and clones every post into a vector.update_posttakes the id and the new title and body.get_mutgives us a mutable reference, so we change the post in place.delete_postremoves the id. If a post was removed we answer204 No Content, if not404 Not Found.
async fn list_posts(State(db): State<Db>) -> Json<Vec<Post>> {
let store = db.lock().unwrap();
Json(store.posts.values().cloned().collect())
}
async fn update_post(
State(db): State<Db>,
Path(id): Path<u32>,
Json(new): Json<NewPost>,
) -> Result<Json<Post>, StatusCode> {
let mut store = db.lock().unwrap();
let post = store.posts.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
post.title = new.title;
post.body = new.body;
Ok(Json(post.clone()))
}
async fn delete_post(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode {
let mut store = db.lock().unwrap();
match store.posts.remove(&id) {
Some(_) => StatusCode::NO_CONTENT,
None => StatusCode::NOT_FOUND,
}
}6. One path, many methods
/posts gets get and post; /posts/{id} gets get, put and delete. Since post is now a method call (.post(...)), the routing import only needs get:
use axum::{extract::{Path, State}, http::StatusCode, routing::get, Json, Router}; let app = Router::new()
.route("/posts", get(list_posts).post(create_post))
.route("/posts/{id}", get(get_post).put(update_post).delete(delete_post))
.with_state(db);7. Test everything with curl
Run the server again and list all posts:
curl -i localhost:3000/postsHTTP/1.1 200 OK
content-type: application/json
content-length: 2
[]An empty list! The posts lived only in memory, so they were gone when the server stopped. A database will fix that later in this series.
Create a post again (the same POST as above, it gets id 1), then update it. -X PUT picks the method:
curl -i localhost:3000/posts/1 -X PUT -H 'content-type: application/json' -d '{"title":"Hello again","body":"Edited"}'HTTP/1.1 200 OK
content-type: application/json
content-length: 46
{"id":1,"title":"Hello again","body":"Edited"}List again: our edited post is in the list.
[{"id":1,"title":"Hello again","body":"Edited"}]Delete it, then delete it again:
curl -i localhost:3000/posts/1 -X DELETE
curl -i localhost:3000/posts/1 -X DELETEHTTP/1.1 204 No Content
HTTP/1.1 404 Not Found
content-length: 0204 No Content means it worked and there is no body. The second time the post is already gone, so 404.
8. The final code
The complete src/main.rs from the end of the video:
use axum::{extract::{Path, State}, http::StatusCode, routing::get, Json, Router};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
#[derive(Serialize, Clone)]
struct Post {
id: u32,
title: String,
body: String,
}
#[derive(Deserialize)]
struct NewPost {
title: String,
body: String,
}
#[derive(Default)]
struct Store {
posts: BTreeMap<u32, Post>,
next_id: u32,
}
type Db = Arc<Mutex<Store>>;
#[tokio::main]
async fn main() {
let db = Db::default();
let app = Router::new()
.route("/posts", get(list_posts).post(create_post))
.route("/posts/{id}", get(get_post).put(update_post).delete(delete_post))
.with_state(db);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on http://localhost:3000");
axum::serve(listener, app).await.unwrap();
}
async fn create_post(State(db): State<Db>, Json(new): Json<NewPost>) -> (StatusCode, Json<Post>) {
let mut store = db.lock().unwrap();
store.next_id += 1;
let post = Post { id: store.next_id, title: new.title, body: new.body };
store.posts.insert(post.id, post.clone());
(StatusCode::CREATED, Json(post))
}
async fn get_post(State(db): State<Db>, Path(id): Path<u32>) -> Result<Json<Post>, StatusCode> {
let store = db.lock().unwrap();
let post = store.posts.get(&id).ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(post.clone()))
}
async fn list_posts(State(db): State<Db>) -> Json<Vec<Post>> {
let store = db.lock().unwrap();
Json(store.posts.values().cloned().collect())
}
async fn update_post(
State(db): State<Db>,
Path(id): Path<u32>,
Json(new): Json<NewPost>,
) -> Result<Json<Post>, StatusCode> {
let mut store = db.lock().unwrap();
let post = store.posts.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
post.title = new.title;
post.body = new.body;
Ok(Json(post.clone()))
}
async fn delete_post(State(db): State<Db>, Path(id): Path<u32>) -> StatusCode {
let mut store = db.lock().unwrap();
match store.posts.remove(&id) {
Some(_) => StatusCode::NO_CONTENT,
None => StatusCode::NOT_FOUND,
}
}Recap
- Shared state:
Arc<Mutex<Store>>, given to the router withwith_state - The
State<Db>extractor (theJsonextractor comes last) E0599/E0308from a missingClone, fixed with#[derive(Serialize, Clone)]- All five CRUD routes:
POST,GET,GETby id,PUT,DELETE, answering201,200,204and404 - In-memory data is gone after a restart
Next episode: error handling with Result, the ? operator and our own error type. Watch the series on YouTube.