Skip to content
Learn Rust
Theme

An HTTP service with axum

axum is a web framework for Rust, built on Tokio. You write plain async functions, and axum works out from their arguments what to pull out of each request. On crates.io, on 25 October 2026, the “recent downloads” figure in its API was about 142 million for axum, against about 12 million for actix-web, 6.5 million for warp and 2.3 million for rocket. A download count includes every project that pulls the crate in indirectly, so it shows how often a framework is depended on, not how many people chose it.

axum’s docs say it “is designed to work with tokio and hyper” (hyper is an HTTP library for Rust), and that runtime and transport layer independence “is not a goal, at least for the time being”. The runtime is covered in Async with Tokio, and futures themselves in Async Basics. This page is about what axum adds.

RememberA handler’s arguments say what it needs from the request. Its return type says what goes back.

Project layout
notes-api/
├── src/
│   ├── bin/
│   │   └── client.rs           the client: cargo run --bin client
│   └── main.rs                 the server: cargo run
├── Cargo.lock                  created by Cargo
└── Cargo.toml                  dependencies go here

Here’s a small notes API with three routes, the kind you’d write on day one at work. It uses serde for the JSON.

RustCargo.toml
[package]
name = "notes-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.9"
reqwest = { version = "0.13.5", features = ["json"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
tokio = { version = "1.54.1", features = ["full"] }
Rustsrc/main.rs
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::get,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Clone, Serialize)]
struct Note {
id: u32,
text: String,
}
#[derive(Deserialize)]
struct NewNote {
text: String,
}
// Shared by every request. Arc = many owners, Mutex = one writer at a time.
#[derive(Clone, Default)]
struct AppState {
notes: Arc<Mutex<Vec<Note>>>,
}
async fn list(State(state): State<AppState>) -> Json<Vec<Note>> {
let notes = state.notes.lock().unwrap();
Json(notes.clone()) // the response owns a copy, so it does not borrow from the lock
}
async fn create(
State(state): State<AppState>,
Json(input): Json<NewNote>, // the request body: it must be the last argument
) -> (StatusCode, Json<Note>) {
let mut notes = state.notes.lock().unwrap();
let note = Note {
id: notes.len() as u32 + 1,
text: input.text,
};
notes.push(note.clone());
(StatusCode::CREATED, Json(note))
}
async fn get_one(
State(state): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<Note>, StatusCode> {
let notes = state.notes.lock().unwrap();
match notes.iter().find(|n| n.id == id) {
Some(note) => Ok(Json(note.clone())),
None => Err(StatusCode::NOT_FOUND),
}
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/notes", get(list).post(create))
.route("/notes/{id}", get(get_one))
.with_state(AppState::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}

We started it and called it with curl. The port, 3000, is the number written in main. This is the real session:

terminal
$ cargo run --bin notes-api
listening on http://127.0.0.1:3000
$ curl -s -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"learn rust"}'
{"id":1,"text":"learn rust"}
$ curl -s -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"ship it"}'
{"id":2,"text":"ship it"}
$ curl -s localhost:3000/notes
[{"id":1,"text":"learn rust"},{"id":2,"text":"ship it"}]
$ curl -s localhost:3000/notes/2
{"id":2,"text":"ship it"}
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/notes/99
404
$ curl -s -w '\n[%{http_code}]\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"txt":"typo"}'
Failed to deserialize the JSON body into the target type: missing field `text` at line 1 column 14
[422]
$ cargo run --bin client
created note 3 with text "sent from reqwest"
the server now has 3 notes

That last command is a second program in the same project, src/bin/client.rs. It calls the API with reqwest, whose docs call it “a convenient, higher-level HTTP Client”. Its Client is asynchronous and, the docs say, requires Tokio, so client.rs has its own #[tokio::main]:

Rustsrc/bin/client.rs
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
struct Note {
id: u32,
text: String,
}
#[tokio::main]
async fn main() -> Result<(), reqwest::Error> {
let client = reqwest::Client::new();
let created: Note = client
.post("http://127.0.0.1:3000/notes")
.json(&json!({ "text": "sent from reqwest" }))
.send()
.await?
.error_for_status()? // turn 4xx and 5xx into errors
.json()
.await?;
println!("created note {} with text {:?}", created.id, created.text);
let all: Vec<Note> = client
.get("http://127.0.0.1:3000/notes")
.send()
.await?
.json()
.await?;
println!("the server now has {} notes", all.len());
Ok(())
}
  • Extractors are the handler’s arguments: State for shared data, Path for URL parts, Json for the body. The docs define one as a type that implements FromRequest or FromRequestParts. They run from left to right, and when one fails axum answers for you. The typo request above got a 422 with a message naming the missing field. The Go version below saved an empty note for the same request.
  • Return types become responses: anything that implements IntoResponse. Here Json<T> is a 200 with JSON, (StatusCode, Json<T>) sets the status, and Result<_, StatusCode> lets you return errors like 404 (the next section shows each status). For an error type of your own, see Errors in real apps; for the Result and ? basics, the Errors lesson.
  • Shared state is Arc<Mutex<...>> inside a struct that derives Clone. The docs say “State is cloned for every request. Wrapping your state in Arc makes those clones cheap.” Every clone of the Arc points at the same notes. Shared State covers Arc, Mutex and why lock() returns a Result (the .unwrap()). The standard Mutex is fine here because no guard is held across an .await; Async with Tokio shows what goes wrong when one is.
  • Routes use {id} for URL parameters, and get(list).post(create) puts two methods on one path.

Below is one fresh server and then nine curl calls to it, with -w printing the status and content type after the body. The first two show the success statuses. The rest are requests axum refuses itself:

terminal
$ cargo run --bin notes-api
listening on http://127.0.0.1:3000
$ curl -s -w '\n[%{http_code} %{content_type}]\n' localhost:3000/notes
[]
[200 application/json]
$ curl -s -w '\n[%{http_code} %{content_type}]\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"learn rust"}'
{"id":1,"text":"learn rust"}
[201 application/json]
$ curl -s -w '\n[%{http_code} %{content_type}]\n' localhost:3000/notes/abc
Invalid URL: Cannot parse `abc` to a `u32`
[400 text/plain; charset=utf-8]
$ curl -s -w '\n[%{http_code} %{content_type}]\n' -X POST localhost:3000/notes -d '{"text":"no content type"}'
Expected request with `Content-Type: application/json`
[415 text/plain; charset=utf-8]
$ curl -s -w '\n[%{http_code} %{content_type}]\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":'
Failed to parse the request body as JSON: text: EOF while parsing a value at line 1 column 8
[400 text/plain; charset=utf-8]
$ curl -s -w '\n[%{http_code} %{content_type}]\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":5}'
Failed to deserialize the JSON body into the target type: text: invalid type: integer `5`, expected a string at line 1 column 9
[422 text/plain; charset=utf-8]
$ curl -s -w '\n[%{http_code} %{content_type}]\n' -X DELETE localhost:3000/notes
[405 ]
$ curl -s -w '\n[%{http_code} %{content_type}]\n' localhost:3000/nowhere
[404 ]
$ curl -s localhost:3000/notes
[{"id":1,"text":"learn rust"}]
  • Path<u32> could not parse abc: 400.
  • The body was sent with no application/json content type: 415. (curl labels a -d body as a form unless you pass -H; man curl says so under -d.)
  • Broken JSON is 400, and valid JSON of the wrong shape is 422. The Json docs list both cases, and the JsonRejection type has a variant for each.
  • A method or path with no route gets 405 or 404, with an empty body in this run. The [405 ] and [404 ] lines show a status and an empty content type: those responses carry no Content-Type header.
  • The final GET shows one note: the server kept the state between calls, and none of the refused requests got as far as create.

These bodies are plain text. To answer differently, the extractor docs show how to take the extractor’s Result yourself and handle the rejection.

axum 0.7 wrote /notes/:id. The 0.7.9 docs show that form, and the 0.8.9 docs use {id}. If you copy a route from an older tutorial into this project, the program stops when it starts, before it serves anything. The number in parentheses is a thread id and can differ from run to run:

$ cargo run --bin notes-api
thread 'main' (3628) panicked at src/main.rs:60:10:
Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Why does list call .clone()? Isn’t cloning the thing to avoid?

Section titled “Why does list call .clone()? Isn’t cloning the thing to avoid?”

Cloning just to make the compiler stop complaining is a habit to avoid, so here is what the compiler is actually objecting to. The value list returns must not borrow from the lock guard, because the guard is dropped when list returns. This is the run with Json(&*notes) instead of Json(notes.clone()):

$ cargo build --bin notes-api
error[E0515]: cannot return value referencing local variable `notes`
  --> src/main.rs:30:5
   |
30 |     Json(&*notes) // borrow the list instead of copying it
   |     ^^^^^^^-----^
   |     |      |
   |     |      `notes` is borrowed here
   |     returns a value referencing data owned by the current function

error[E0515]: cannot return value referencing local data `state.notes`
  --> src/main.rs:30:5
   |
29 |     let notes = state.notes.lock().unwrap();
   |                 ----------- `state.notes` is borrowed here
30 |     Json(&*notes) // borrow the list instead of copying it
   |     ^^^^^^^^^^^^^ returns a value referencing data owned by the current function

There are two ways out. One is what main.rs does: clone the notes, so the response owns them, at the cost of a copy of every note on each call. The other is to turn the borrow into a Response before returning, with Json(&*notes).into_response(), which serializes the notes while the lock is still held and copies none. This is the run of that version, which compiles and answers the same way:

terminal
$ cargo run --bin notes-api
listening on http://127.0.0.1:3000
$ curl -s -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"learn rust"}'
{"id":1,"text":"learn rust"}
$ curl -s localhost:3000/notes
[{"id":1,"text":"learn rust"}]

Go’s version holds its lock until list returns too, and that includes writing the response (defer a.mu.Unlock()).

Go
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
)
type Note struct {
ID int `json:"id"`
Text string `json:"text"`
}
type App struct {
mu sync.Mutex
notes []Note
}
func (a *App) list(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
writeJSON(w, http.StatusOK, a.notes)
}
func (a *App) create(w http.ResponseWriter, r *http.Request) {
var input struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
a.mu.Lock()
defer a.mu.Unlock()
note := Note{ID: len(a.notes) + 1, Text: input.Text}
a.notes = append(a.notes, note)
writeJSON(w, http.StatusCreated, note)
}
func (a *App) getOne(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
a.mu.Lock()
defer a.mu.Unlock()
for _, n := range a.notes {
if n.ID == id {
writeJSON(w, http.StatusOK, n)
return
}
}
w.WriteHeader(http.StatusNotFound)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
app := &App{notes: []Note{}}
mux := http.NewServeMux()
mux.HandleFunc("GET /notes", app.list)
mux.HandleFunc("POST /notes", app.create)
mux.HandleFunc("GET /notes/{id}", app.getOne)
fmt.Println("listening on http://127.0.0.1:3000")
http.ListenAndServe("127.0.0.1:3000", mux)
}
$ go run .
listening on http://127.0.0.1:3000
$ curl -s -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"learn rust"}'
{"id":1,"text":"learn rust"}
$ curl -s -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"ship it"}'
{"id":2,"text":"ship it"}
$ curl -s localhost:3000/notes
[{"id":1,"text":"learn rust"},{"id":2,"text":"ship it"}]
$ curl -s localhost:3000/notes/2
{"id":2,"text":"ship it"}
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/notes/99
404
$ curl -s -w '\n[%{http_code}]\n' -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"txt":"typo"}'
{"id":3,"text":""}

[201]
$ go run ./client
created {ID:4 Text:sent from Go}
the server now has 4 notes

Go’s standard library has the server and the client (net/http). The decoder leaves a missing field at its zero value, so the typo request returned 201 and saved a note with empty text. In Go you would write that check yourself, where axum’s Json<NewNote> refuses the request before create runs.

For a function to be a handler, the Handler docs list the rules: it is an async fn, takes at most 16 arguments that implement Send, all but the last argument implement FromRequestParts and the last implements FromRequest, it returns something that implements IntoResponse, and “Returns a future that is Send.” The same page says the most common way to break the last rule is to hold a !Send type across an .await (Send and Sync explains the trait). Break a rule and you may get a long Handler<_, _> error like the one in the gotcha below; I ran only the argument-order case. The macros feature adds #[axum::debug_handler], which generates better error messages for the function it is on. This is the gotcha’s code with the attribute added, and the feature turned on in Cargo.toml:

RustCargo.toml
[package]
name = "notes-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8.9", features = ["macros"] }
serde = { version = "1.0.229", features = ["derive"] }
tokio = { version = "1.54.1", features = ["full"] }
$ cargo build
error: `Json<_>` consumes the request body and thus must be the last argument to the handler function
  --> src/main.rs:23:18
   |
23 |     Json(input): Json<NewNote>, // the body first...
   |                  ^^^^

error[E0277]: the trait bound `fn(Json<NewNote>, State<AppState>) -> _ {create}: Handler<_, _>` is not satisfied
   --> src/main.rs:38:31
    |
 38 |         .route("/notes", post(create))
    |                          ---- ^^^^^^ unsatisfied trait bound
    |                          |
    |                          required by a bound introduced by this call
    |
    = help: the trait `Handler<_, _>` is not implemented for fn item `fn(Json<NewNote>, State<AppState>) -> impl Future<Output = (StatusCode, Json<Note>)> {create}`
    = note: Consider using `#[axum::debug_handler]` to improve the error message
  ... (notes pointing into library source code trimmed)

The attribute’s message is the first error, in plain words. The long Handler error still comes after it in this run.

Only the Go service was run on this page. The other rows name some options, with a link to each one’s docs or site, and nothing in them was run.

Language Web server HTTP client Ships with the language
Go net/http net/http both, in the standard library
Python FastAPI, Flask httpx, requests http.server, which its docs say is “not recommended for production”, and urllib.request
TypeScript Express, Fastify, Hono fetch Node’s http module and its global fetch
Java Spring Boot HttpClient the client, and a small server in the JDK
Kotlin Ktor, Spring Boot Ktor client on the JVM, the JDK ones in the Java row
C# ASP.NET Core HttpClient HttpClient is in .NET; ASP.NET Core is Microsoft’s web framework for .NET
C libmicrohttpd libcurl neither
C++ Drogon, Crow cpr, libcurl neither
Rust axum reqwest neither: both are crates

I did all three in the image behind this page. The third one used the gotcha’s smaller project, which has one route.

  1. Break the id. curl localhost:3000/notes/abc answers 400 with “Cannot parse abc to a u32” and a plain-text body, as the status session shows. get_one never ran: Path<u32> refused the request first.
  2. Use the old route syntax. Change {id} to :id in main.rs and run it. The program panics at startup with a message that tells you to use {capture}.
  3. Ask for a clearer error. In the gotcha’s one-route project (the body is first there), add features = ["macros"] to the axum line and #[axum::debug_handler] above create. The first error now says the body extractor must be last.
Gotcha

Putting the request body first

Looks right at a glance, but Rust won't compile it.

No direct equivalent in Go. Just a Rust rule.Not run in Kotlin on this page.Not run in Python on this page.Not run in TypeScript on this page.Not run in Java on this page.Not run in C# on this page.Not run in C on this page.Not run in C++ on this page.
Rustsrc/main.rs✗ won't compile
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Clone, Serialize)]
struct Note {
id: u32,
text: String,
}
#[derive(Deserialize)]
struct NewNote {
text: String,
}
#[derive(Clone, Default)]
struct AppState {
notes: Arc<Mutex<Vec<Note>>>,
}
async fn create(
Json(input): Json<NewNote>, // the body first...
State(state): State<AppState>,
) -> (StatusCode, Json<Note>) {
let mut notes = state.notes.lock().unwrap();
let note = Note {
id: notes.len() as u32 + 1,
text: input.text,
};
notes.push(note.clone());
(StatusCode::CREATED, Json(note))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/notes", post(create))
.with_state(AppState::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
Gono equivalent

In Go you choose where in the handler to read the body, once, with json.NewDecoder(r.Body), as this page's Go service does.

There is no Kotlin version of this one.

There is no Python version of this one.

There is no TypeScript version of this one.

There is no Java version of this one.

There is no C# version of this one.

There is no C version of this one.

There is no C++ version of this one.

What does the compiler say? Guess first, then open.
$ cargo buildWhat is E0277?
error[E0277]: the trait bound `fn(Json<NewNote>, State<AppState>) -> _ {create}: Handler<_, _>` is not satisfied
   --> src/main.rs:37:31
    |
 37 |         .route("/notes", post(create))
    |                          ---- ^^^^^^ unsatisfied trait bound
    |                          |
    |                          required by a bound introduced by this call
    |
    = help: the trait `Handler<_, _>` is not implemented for fn item `fn(Json<NewNote>, State<AppState>) -> impl Future<Output = (StatusCode, Json<Note>)> {create}`
    = note: Consider using `#[axum::debug_handler]` to improve the error message
  ... (notes pointing into library source code trimmed)
What happens in Go

In Go you choose where in the handler to read the body, once, with json.NewDecoder(r.Body), as this page's Go service does.

Why it looks fine

When the request reaches your code as one object, you can read the body at any point in the function. Here the order of the arguments is the order in which the parts are extracted.

Why it looks fine

When the request reaches your code as one object, you can read the body at any point in the function. Here the order of the arguments is the order in which the parts are extracted.

Why it looks fine

When the request reaches your code as one object, you can read the body at any point in the function. Here the order of the arguments is the order in which the parts are extracted.

Why it looks fine

When the request reaches your code as one object, you can read the body at any point in the function. Here the order of the arguments is the order in which the parts are extracted.

Why it looks fine

When the request reaches your code as one object, you can read the body at any point in the function. Here the order of the arguments is the order in which the parts are extracted.

Why it looks fine

When the request reaches your code as one object, you can read the body at any point in the function. Here the order of the arguments is the order in which the parts are extracted.

Why it looks fine

When the request reaches your code as one object, you can read the body at any point in the function. Here the order of the arguments is the order in which the parts are extracted.

What Rust sees

In axum the handler's arguments are the extractors, and they run from left to right. The docs say the request body is "an asynchronous stream that can only be consumed once", so axum requires an extractor that consumes it (Json, String, Bytes) to be the last argument.

Why Rust says no

The error is long because axum works this out through traits: a function whose arguments are in the wrong order no longer counts as a handler. When you see "the trait Handler<_, _> is not implemented", check the argument order first. With axum's macros feature, #[axum::debug_handler] above the function puts a short message first: "Json<_> consumes the request body and thus must be the last argument to the handler function" (the long error still follows it in the run above).

The fix

Compiles and runs.

Rust, fixedsrc/main.rsRun ↗
use axum::{Json, Router, extract::State, http::StatusCode, routing::post};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
#[derive(Clone, Serialize)]
struct Note {
id: u32,
text: String,
}
#[derive(Deserialize)]
struct NewNote {
text: String,
}
#[derive(Clone, Default)]
struct AppState {
notes: Arc<Mutex<Vec<Note>>>,
}
async fn create(
State(state): State<AppState>,
Json(input): Json<NewNote>, // the request body: it must be the last argument
) -> (StatusCode, Json<Note>) {
let mut notes = state.notes.lock().unwrap();
let note = Note {
id: notes.len() as u32 + 1,
text: input.text,
};
notes.push(note.clone());
(StatusCode::CREATED, Json(note))
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/notes", post(create))
.with_state(AppState::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
println!("listening on http://127.0.0.1:3000");
axum::serve(listener, app).await.unwrap();
}
$ curl -s -X POST localhost:3000/notes -H 'content-type: application/json' -d '{"text":"learn rust"}'
{"id":1,"text":"learn rust"}