Serde
Required fields, field types, JSON syntax, renaming, defaults, and unknown-field policy.
Build Axum routes with Path, Query, Json, and State extractors. Rust examples cover handlers, validation, responses, and error handling.
Axum turns an incoming HTTP request into values your function can use, runs your async function, then turns its return value into an HTTP response.
A struct groups data, an enum describes one of several cases, a trait describes behavior, and async fn can pause at .await without blocking an operating-system thread.
cargo new notes-api
cd notes-api
[package]
name = "notes-api"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = { version = "0.8.9", features = ["macros", "multipart", "ws", "http2"] }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util", "timeout", "load-shed"] }
tower-http = { version = "0.6", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
futures-util = "0.3"
tokio-stream = "0.1"
[dev-dependencies]
http-body-util = "0.1"
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(|| async { "Hello from Axum" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
#[tokio::main] starts the async runtime. Router stores routes. TcpListener listens for network connections. axum::serve connects the listener to the app.
A route combines a path with one or more HTTP methods. Axum gives fixed paths priority over dynamic paths.
use axum::{
extract::Path,
routing::{delete, get, patch, post},
Router,
};
let app = Router::new()
.route("/health", get(health))
.route("/notes", get(list_notes).post(create_note))
.route("/notes/{id}", get(show_note).patch(update_note).delete(delete_note))
.route("/assets/{*path}", get(asset));
async fn health() -> &'static str { "ok" }
async fn list_notes() {}
async fn create_note() {}
async fn show_note(Path(id): Path<u64>) {}
async fn update_note(Path(id): Path<u64>) {}
async fn delete_note(Path(id): Path<u64>) {}
async fn asset(Path(path): Path<String>) {}
/healthMatches one exact path.
/notes/{id}Captures one segment as id.
/assets/{*path}Captures the remaining non-empty path.
| Function | Matches | Typical purpose |
|---|---|---|
| get | GET; Axum also supplies HEAD behavior | Read a resource |
| post | POST | Create or perform an action |
| put | PUT | Replace a resource |
| patch | PATCH | Partially update a resource |
| delete | DELETE | Delete a resource |
| head | HEAD | Headers without a response body |
| options | OPTIONS | Capabilities and CORS preflight |
| connect | CONNECT | HTTP tunnels and proxies |
| trace | TRACE | HTTP diagnostics; rarely enabled |
| any | Every method | Protocol upgrades or catch-all logic |
| on | A MethodFilter combination | Custom groups such as GET | HEAD |
use axum::{http::StatusCode, routing::get, Router};
fn api_routes() -> Router {
Router::new()
.route("/notes", get(list_notes))
.route("/users", get(list_users))
}
let status_routes = Router::new().route("/health", get(health));
let app = Router::new()
.nest("/api/v1", api_routes())
.merge(status_routes)
.fallback(|| async {
(StatusCode::NOT_FOUND, "route not found")
});
nest adds a path prefix and strips it before the inner router sees the URI. OriginalUri preserves the URI received from the client. merge combines routers without adding a prefix.
A handler is normally an async fn. Its arguments are extractors and its return value must implement IntoResponse.
async fn update_note(
State(state): State<AppState>, // request parts: does not consume body
Path(id): Path<u64>, // request parts
headers: HeaderMap, // request parts
Json(input): Json<UpdateNote>, // body: must be last
) -> Result<Json<Note>, AppError> {
let note = state.notes.update(id, input).await?;
Ok(Json(note))
}
Only the final argument may consume the request body. Put Json, Form, String, Bytes, Request, or another FromRequest extractor last. Earlier arguments must implement FromRequestParts.
IntoResponse.Send. Do not hold a non-Send value or a standard-library lock guard across .await.Clone + Send + 'static. Use move when capturing owned values.use axum::debug_handler;
#[debug_handler]
async fn difficult_handler(
Json(input): Json<CreateNote>,
) -> Result<Json<Note>, AppError> {
// The macro reports why this function is not a valid Handler.
todo!()
}
debug_handler and debug_middleware require Axum’s macros feature and are most useful while fixing type errors.
An extractor validates and converts part of a request. If it fails, Axum returns its rejection response and never calls the handler.
| Extractor | Reads | Use it for | Body? |
|---|---|---|---|
| Path<T> | Route captures | /notes/{id} values deserialized with Serde | No |
| Query<T> | Query string | ?page=2&tag=rust | No |
| Json<T> | JSON body | Typed API input | Yes |
| Form<T> | Query on GET/HEAD; URL-encoded body otherwise | HTML forms | Sometimes |
| State<T> | Router state | Database pools, configuration, clients | No |
| Extension<T> | Request extensions | Values inserted by middleware | No |
| HeaderMap | All headers | Authorization and metadata | No |
| Method / Uri / Version | Request line | HTTP metadata | No |
| ConnectInfo<T> | Connection extension | Peer address or custom connection data | No |
| MatchedPath | Matched route template | Metrics labels such as /notes/{id} | No |
| OriginalUri | Original URI | URI before nested prefixes were stripped | No |
| NestedPath | Nested prefix | Path at which a router was nested | No |
| RawQuery | Unparsed query | Exact raw query text | No |
| RawPathParams | Undeserialized captures | Iterating raw key/value path pairs | No |
| Bytes | Buffered body | Binary data | Yes |
| String | Buffered UTF-8 body | Plain text | Yes |
| Body | Streaming body | Forwarding or incremental processing | Yes |
| Request | Whole request | Full low-level control | Yes |
| RawForm | Raw URL-encoded data | Manual form parsing | Yes |
| Multipart | Multipart body | Files and mixed form fields | Yes |
| WebSocketUpgrade | Upgrade request | Switching HTTP to WebSocket | Yes |
use axum::{
extract::{Path, Query},
http::{header::USER_AGENT, HeaderMap},
Json,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Filters {
page: Option<u32>,
archived: Option<bool>,
}
#[derive(Serialize)]
struct ResultPage {
notebook: String,
page: u32,
client: String,
}
async fn list_notebook(
Path(notebook): Path<String>,
Query(filters): Query<Filters>,
headers: HeaderMap,
) -> Json<ResultPage> {
let client = headers
.get(USER_AGENT)
.and_then(|value| value.to_str().ok())
.unwrap_or("unknown")
.to_owned();
Json(ResultPage {
notebook,
page: filters.page.unwrap_or(1),
client,
})
}
Option<T>The supported absence of an extractor becomes None. Not every failure is treated as absence; each extractor controls this through OptionalFromRequest or OptionalFromRequestParts.
Result<T, T::Rejection>Your handler receives either the value or its rejection, letting you replace Axum’s default error response locally.
The same wrapper can often be an extractor on input and a response on output. Serde performs the conversion between wire data and Rust structs.
use axum::{http::StatusCode, Json};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateNote {
title: String,
body: String,
}
#[derive(Serialize)]
struct Note {
id: u64,
title: String,
body: String,
}
async fn create_note(
Json(input): Json<CreateNote>,
) -> (StatusCode, Json<Note>) {
let note = Note {
id: 42,
title: input.title,
body: input.body,
};
(StatusCode::CREATED, Json(note))
}
Json<T> requires a JSON content type, valid JSON syntax, and fields that deserialize into T. Json::from_bytes handles special cases where you have already buffered the body.
use axum::{response::Html, Form};
use serde::Deserialize;
#[derive(Deserialize)]
struct Login {
email: String,
password: String,
}
async fn login(Form(input): Form<Login>) -> Html<String> {
Html(format!("<p>Signed in as {}</p>", input.email))
}
For GET and HEAD, Form<T> reads the query string. For other methods it reads an application/x-www-form-urlencoded request body. It does not parse multipart file uploads; use Multipart.
Serde parses input into Rust types. Validation checks application rules such as length, range, and format. Run both before database writes or other business logic.
Garde declares rules on Rust fields. axum-valid wraps Axum extractors and returns 400 Bad Request when those rules fail. The handler runs only after parsing and validation succeed.
[dependencies]
garde = { version = "0.23", features = ["derive", "email"] }
axum-valid = { version = "0.25", default-features = false, features = ["basic", "garde"] }
use axum::{http::StatusCode, Json};
use axum_valid::Garde;
use garde::Validate;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Validate)]
struct CreateAccount {
#[garde(email)]
email: String,
#[garde(length(min = 12, max = 128))]
password: String,
#[garde(range(min = 13, max = 120))]
age: u8,
}
#[derive(Serialize)]
struct CreatedAccount {
id: u64,
email: String,
}
async fn create_account(
Garde(Json(input)): Garde<Json<CreateAccount>>,
) -> (StatusCode, Json<CreatedAccount>) {
// This point is reached only when JSON parsing and every rule passed.
(
StatusCode::CREATED,
Json(CreatedAccount { id: 42, email: input.email }),
)
}
Required fields, field types, JSON syntax, renaming, defaults, and unknown-field policy.
Length, ranges, email, URL, patterns, nested values, conditional rules, and custom functions.
Composes validation with Json, Query, Path, Form, and optional ecosystem extractors.
#[derive(Deserialize, Validate)]
struct Address {
#[garde(length(min = 2, max = 80))]
city: String,
}
#[derive(Deserialize, Validate)]
struct OrderInput {
#[garde(dive)]
shipping: Address,
// The vector needs 1..=20 items; every item must be non-empty.
#[garde(length(min = 1, max = 20), inner(length(min = 1)))]
product_codes: Vec<String>,
#[garde(custom(not_reserved))]
display_name: String,
}
fn not_reserved(value: &str, _ctx: &()) -> garde::Result {
match value.to_ascii_lowercase().as_str() {
"admin" | "root" => Err(garde::Error::new("name is reserved")),
_ => Ok(()),
}
}
Keep raw values as data, encode them for the output context, bind them as database parameters, and still check who may perform the action. Normalize only by an explicit rule—for example, trim an email before uniqueness checks—and keep validation error responses free of internal details.
Axum accepts anything implementing IntoResponse. Tuples let you combine a status, headers, extensions, and a body without manually building an HTTP response.
| Return value | Result |
|---|---|
| () | Empty 200 response |
| &str / String | Plain text, UTF-8 content type |
| Vec<u8> / Bytes | Binary response |
| Json<T> | Serialized JSON with JSON content type |
| Html<T> | HTML content type |
| StatusCode | Empty response with that status |
| NoContent | 204 No Content |
| Redirect | Redirect with a Location header |
| HeaderMap / [(K, V); N] | Empty response carrying headers |
| (StatusCode, R) | Overrides the status of response R |
| (headers, R) | Adds headers without changing R’s body |
| (StatusCode, headers, R) | Status + headers + response body |
| Response | Low-level complete response |
| Result<T, E> | Either branch becomes a response |
use axum::{
body::Body,
http::{header, Response, StatusCode},
response::{AppendHeaders, Html, IntoResponse, NoContent, Redirect},
};
async fn created() -> impl IntoResponse {
(
StatusCode::CREATED,
[(header::LOCATION, "/notes/42")],
"created",
)
}
async fn deleted() -> NoContent {
NoContent
}
async fn moved() -> Redirect {
Redirect::temporary("/new-location")
}
async fn page() -> Html<&'static str> {
Html("<h1>Notes</h1>")
}
async fn repeated_headers() -> impl IntoResponse {
(
AppendHeaders([
("set-cookie", "theme=dark; Path=/"),
("set-cookie", "density=compact; Path=/"),
]),
"preferences saved",
)
}
async fn low_level() -> Response<Body> {
Response::builder()
.status(StatusCode::ACCEPTED)
.header("x-job-id", "job-42")
.body(Body::from("queued"))
.unwrap()
}
A tuple status can overwrite an inner response’s status. For example, (StatusCode::IM_A_TEAPOT, Json(value)) stays 418 even if JSON serialization fails internally. Use a custom response type when serialization errors must control the status.
State is data cloned into the application service and borrowed by handlers. A database pool is already designed to be cloned; other shared values commonly use Arc.
use axum::{extract::State, routing::{get, post}, Json, Router};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
struct AppState {
notes: Arc<RwLock<Vec<Note>>>,
}
#[derive(Clone, Deserialize, Serialize)]
struct Note {
title: String,
}
async fn list(State(state): State<AppState>) -> Json<Vec<Note>> {
let notes = state.notes.read().await;
Json(notes.clone())
}
async fn create(
State(state): State<AppState>,
Json(note): Json<Note>,
) {
state.notes.write().await.push(note);
}
let state = AppState {
notes: Arc::new(RwLock::new(Vec::new())),
};
let app = Router::new()
.route("/notes", get(list).post(create))
.with_state(state);
Router<AppState> means “this router is still missing an AppState,” not “this router contains AppState.” After .with_state(state), it normally becomes Router<()> and can be served.
use axum::extract::{FromRef, State};
#[derive(Clone)]
struct AppState {
config: Config,
database: DatabasePool,
}
#[derive(Clone)]
struct Config {
public_url: String,
}
impl FromRef<AppState> for Config {
fn from_ref(state: &AppState) -> Self {
state.config.clone()
}
}
async fn public_url(State(config): State<Config>) -> String {
config.public_url
}
With the macros feature, #[derive(FromRef)] can generate field conversions. Prefer State for compile-time safety. Use Extension for per-request data added by middleware or when dynamic extension storage is specifically useful.
In a handler, Err is still a normal HTTP response when the error type implements IntoResponse. True Tower service errors must be converted before reaching Hyper.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
enum AppError {
NotFound,
Validation(String),
Internal,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
Self::NotFound => (StatusCode::NOT_FOUND, "note not found".to_owned()),
Self::Validation(message) => (StatusCode::UNPROCESSABLE_ENTITY, message),
Self::Internal => {
// Log the real internal error separately. Do not leak it.
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
async fn show_note() -> Result<Json<Note>, AppError> {
Err(AppError::NotFound)
}
use axum::{
extract::rejection::JsonRejection,
http::StatusCode,
Json,
};
use serde_json::{json, Value};
async fn create_note(
payload: Result<Json<CreateNote>, JsonRejection>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let Json(input) = payload.map_err(|rejection| {
(
rejection.status(),
Json(json!({ "error": rejection.body_text() })),
)
})?;
Ok(Json(json!({ "saved": input.title })))
}
Major built-in rejection families include JsonRejection, FormRejection, QueryRejection, PathRejection, BytesRejection, StringRejection, ExtensionRejection, RawFormRejection, RawPathParamsRejection, and MatchedPathRejection. They are non-exhaustive: keep a catch-all match arm.
use axum::{
error_handling::HandleErrorLayer,
http::StatusCode,
BoxError, Router,
};
use std::time::Duration;
use tower::ServiceBuilder;
let app = Router::new()
// add routes first
.layer(
ServiceBuilder::new()
.layer(HandleErrorLayer::new(|error: BoxError| async move {
if error.is::<tower::timeout::error::Elapsed>() {
(StatusCode::REQUEST_TIMEOUT, "request timed out")
} else {
(StatusCode::INTERNAL_SERVER_ERROR, "service failed")
}
}))
.timeout(Duration::from_secs(10)),
);
Allow Google Analytics to measure page visits and use of the guides? It uses cookies after you accept. Your choice won’t affect access to the site. Privacy notice