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.
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.
[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"] }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:
$ 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]:
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:
Statefor shared data,Pathfor URL parts,Jsonfor the body. The docs define one as a type that implementsFromRequestorFromRequestParts. They run from left to right, and when one fails axum answers for you. The typo request above got a422with 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. HereJson<T>is a 200 with JSON,(StatusCode, Json<T>)sets the status, andResult<_, StatusCode>lets you return errors like404(the next section shows each status). For an error type of your own, see Errors in real apps; for theResultand?basics, the Errors lesson. - Shared state is
Arc<Mutex<...>>inside a struct that derivesClone. The docs say “State is cloned for every request. Wrapping your state in Arc makes those clones cheap.” Every clone of theArcpoints at the same notes. Shared State coversArc,Mutexand whylock()returns aResult(the.unwrap()). The standardMutexis 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, andget(list).post(create)puts two methods on one path.
What axum answers before your code runs
Section titled “What axum answers before your code runs”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:
$ 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 parseabc: 400.- The body was sent with no
application/jsoncontent type: 415. (curl labels a-dbody as a form unless you pass-H;man curlsays so under-d.) - Broken JSON is 400, and valid JSON of the wrong shape is 422. The
Jsondocs list both cases, and theJsonRejectiontype 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 noContent-Typeheader. - The final
GETshows one note: the server kept the state between calls, and none of the refused requests got as far ascreate.
These bodies are plain text. To answer differently, the extractor docs show how to take the extractor’s Result yourself and handle the rejection.
The route syntax changed in 0.8
Section titled “The route syntax changed in 0.8”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:
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 backtraceWhy 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()):
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:
$ 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()).
The same service in Go
Section titled “The same service in 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)}package main
import ( "bytes" "encoding/json" "fmt" "net/http")
type Note struct { ID int `json:"id"` Text string `json:"text"`}
func main() { body, _ := json.Marshal(map[string]string{"text": "sent from Go"}) resp, err := http.Post("http://127.0.0.1:3000/notes", "application/json", bytes.NewReader(body)) if err != nil { panic(err) } defer resp.Body.Close() if resp.StatusCode >= 400 { panic(resp.Status) } var created Note json.NewDecoder(resp.Body).Decode(&created) fmt.Printf("created %+v\n", created)
resp2, err := http.Get("http://127.0.0.1:3000/notes") if err != nil { panic(err) } defer resp2.Body.Close() var all []Note json.NewDecoder(resp2.Body).Decode(&all) fmt.Printf("the server now has %d notes\n", len(all))}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/99404$ 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 ./clientcreated {ID:4 Text:sent from Go}
the server now has 4 notesGo’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.
When a handler will not compile
Section titled “When a handler will not compile”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:
[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"] }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.
The same job in your language
Section titled “The same job in your language”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 |
Try it yourself
Section titled “Try it yourself”I did all three in the image behind this page. The third one used the gotcha’s smaller project, which has one route.
- Break the id.
curl localhost:3000/notes/abcanswers400with “Cannot parseabcto au32” and a plain-text body, as the status session shows.get_onenever ran:Path<u32>refused the request first. - Use the old route syntax. Change
{id}to:idinmain.rsand run it. The program panics at startup with a message that tells you to use{capture}. - Ask for a clearer error. In the gotcha’s one-route project (the body is first there), add
features = ["macros"]to theaxumline and#[axum::debug_handler]abovecreate. The first error now says the body extractor must be last.
Read more
Section titled “Read more”- axum on docs.rs: the overview, with routing, handlers, extractors, responses and state in one page.
- Extractors, and the order they run in, and how to handle a rejection yourself.
- Sharing state with handlers and the
Stateextractor. #[debug_handler]andRouter::route(captures and wildcards).- axum’s examples in its repository.
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.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();}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.
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)
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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();}{"id":1,"text":"learn rust"}Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.