Responses

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against axum 0.8.9 on Rust 1.99 (edition 2024), checked on 2026-10-06. Whatever a handler returns must implement IntoResponse, a one-method trait that turns a value into an http::Response<axum::body::Body>. Because so many types already implement it — including tuples that combine a status, headers and a body — most handlers never build a Response by hand. Errors are a special case of responses and have their own page, Error Handling.

Where it runs in the request

In the request lifecycle (tokio accepts the connection, hyper parses the request, tower layers wrap the router, the router matches a route, middleware and extractors run, the handler runs, the response travels back out), IntoResponse runs right after the handler returns and turns its value into an http::Response. That response then travels back out through the middleware added with Router::layer, the outer tower layers and hyper, which writes it to the connection. A streaming body (SSE, files) keeps flowing after the handler has finished; rejections from extractors and errors from fallible layers become responses through the same trait.

IntoResponse for common types

Return type Status Notes

()

200

empty body

&'static str, String, Cow<'static, str>

200

content-type: text/plain; charset=utf-8

Vec<u8>, Bytes, &'static [u8]

200

content-type: application/octet-stream

StatusCode

that status

empty body

Json<T> where T: Serialize

200

content-type: application/json; a serialisation error becomes a 500

Html<T>

200

content-type: text/html; charset=utf-8

Redirect::to(uri) / temporary / permanent

303 / 307 / 308

location header; an invalid URI becomes a 500 (since 0.8.5)

NoContent

204

added in 0.8, the self-describing form of StatusCode::NO_CONTENT

Form<T>

200

application/x-www-form-urlencoded body

Sse<S>

200

a Server-Sent Events stream (see SSE & Streaming)

Response / http::Response<B>

as built

full control

Body

200

any body, including a stream

Result<T, E> where both implement it

either

the Ok or Err response

HeaderMap, [(K, V); N]

200

headers only

Extension<T>

 — 

inserts T into the response extensions (read by middleware)

The three Redirect constructors map to the three redirect semantics: to (303 See Other) after a form POST, so the browser follows with a GET; temporary (307) and permanent (308) preserve the method and body.

Tuples: status, parts, body

A tuple is a response when its last element implements IntoResponse and the elements before it implement IntoResponseParts. Optionally, the first element can be a StatusCode (or an http::response::Parts), which overrides the status. The allowed shapes are:

  • (StatusCode, B)

  • (StatusCode, P1, ..., Pn, B) and (P1, ..., Pn, B) with up to 16 parts

  • (http::response::Parts, P1, ..., B) and (http::Response<()>, P1, ..., B) to start from an existing response

Header arrays [(K, V); N], HeaderMap, TypedHeader<H>, cookie jars, Extension<T> and AppendHeaders are response parts.

use axum::{
    Json, Router,
    http::{HeaderName, StatusCode, header},
    response::{AppendHeaders, Html, IntoResponse, NoContent, Redirect},
    routing::{delete, get, post},
};
use serde::Serialize;

#[derive(Serialize)]
struct Book {
    isbn: &'static str,
    title: &'static str,
}

// 201 Created + Location header + JSON body
async fn create_book() -> impl IntoResponse {
    (
        StatusCode::CREATED,
        [(header::LOCATION, "/api/v1/books/9780441013593")],
        Json(Book { isbn: "9780441013593", title: "Dune" }),
    )
}

// several headers, one of them repeated
async fn cover() -> impl IntoResponse {
    (
        [(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=86400")],
        AppendHeaders([
            (HeaderName::from_static("x-shelf"), "science-fiction"),
            (HeaderName::from_static("x-shelf"), "classics"),
        ]),
        "<svg xmlns=\"http://www.w3.org/2000/svg\"/>",
    )
}

async fn remove_book() -> NoContent {
    NoContent
}

async fn old_catalogue() -> Redirect {
    Redirect::permanent("/catalogue")
}

async fn catalogue() -> Html<&'static str> {
    Html("<h1>Catalogue</h1>")
}

fn app() -> Router {
    Router::new()
        .route("/api/v1/books", post(create_book))
        .route("/api/v1/books/{isbn}", delete(remove_book))
        .route("/covers/{isbn}", get(cover))
        .route("/books.html", get(old_catalogue))
        .route("/catalogue", get(catalogue))
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app()).await.unwrap();
}
$ curl -si -X POST localhost:3000/api/v1/books
HTTP/1.1 201 Created
content-type: application/json
location: /api/v1/books/9780441013593
content-length: 39

{"isbn":"9780441013593","title":"Dune"}
$ curl -sI localhost:3000/covers/1 | grep -i x-shelf
x-shelf: science-fiction
x-shelf: classics

Header arrays insert (replacing an existing header of the same name), while AppendHeaders appends, which matters for Set-Cookie, Link and Vary. Parts are applied left to right, after the body has set its own content-type, so a header array can override the content type of a String or Json body. A header value that is not valid ("\n", for example) turns the response into a 500 rather than panicking.

Implementing IntoResponse for your types

Implement the trait for domain types that always map to the same response:

use axum::{
    Json, Router,
    http::{StatusCode, header},
    response::{IntoResponse, Response},
    routing::get,
};
use serde::Serialize;

#[derive(Serialize)]
struct BookPage {
    items: Vec<String>,
    page: u32,
    total_pages: u32,
}

impl IntoResponse for BookPage {
    fn into_response(self) -> Response {
        let link = format!("</api/v1/books?page={}>; rel=\"next\"", self.page + 1);
        let has_next = self.page < self.total_pages;
        let mut response = Json(self).into_response();
        if has_next {
            response.headers_mut().insert(header::LINK, link.parse().expect("valid header value"));
        }
        response
    }
}

async fn list() -> BookPage {
    BookPage { items: vec!["Dune".into()], page: 1, total_pages: 3 }
}

async fn teapot() -> Response {
    // building a Response manually is always possible
    Response::builder().status(StatusCode::IM_A_TEAPOT).body("no coffee here".into()).unwrap()
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books", get(list)).route("/coffee", get(teapot));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Implement IntoResponseParts instead when a type should contribute headers or extensions to any response, such as a RequestId that sets an x-request-id header.

Returning different response types

A function has one return type, so returning a Json on one branch and a StatusCode on another needs a common type. The options, from most to least common:

use axum::{
    Json, Router,
    extract::Path,
    http::StatusCode,
    response::{Html, IntoResponse, Redirect, Response},
    routing::get,
};
use axum_extra::either::Either;

// 1. Result: success and error types both implement IntoResponse
async fn by_result(Path(id): Path<u32>) -> Result<Json<u32>, StatusCode> {
    if id == 0 { Err(StatusCode::NOT_FOUND) } else { Ok(Json(id)) }
}

// 2. Response: convert each branch with .into_response()
async fn by_response(Path(id): Path<u32>) -> Response {
    match id {
        0 => Redirect::to("/books").into_response(),
        1 => Html("<p>Dune</p>").into_response(),
        _ => StatusCode::NOT_FOUND.into_response(),
    }
}

// 3. Either: two (or up to eight) concrete types, no conversion calls
async fn by_either(Path(id): Path<u32>) -> Either<Html<&'static str>, Redirect> {
    if id == 1 { Either::E1(Html("<p>Dune</p>")) } else { Either::E2(Redirect::to("/books")) }
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/a/{id}", get(by_result))
        .route("/b/{id}", get(by_response))
        .route("/c/{id}", get(by_either));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

impl IntoResponse is convenient but opaque: all branches must still return the same concrete type, because impl Trait in return position stands for one hidden type, so mixing Html and Redirect under impl IntoResponse fails with E0308 ("mismatched types"). The axum documentation also notes that impl IntoResponse hides the type from tests and from #[debug_handler]; prefer concrete types in library code.

ErrorResponse and response::Result

axum::response::Result<T> is Result<T, ErrorResponse>, where ErrorResponse can be created from any IntoResponse with ? or .into(). It lets a handler use ? on several error types that already implement IntoResponse without defining an enum:

use axum::{
    Json, Router,
    extract::Path,
    http::StatusCode,
    response::{ErrorResponse, Result},
    routing::get,
};

fn parse_isbn(text: &str) -> Result<u64, (StatusCode, &'static str)> {
    if text.len() == 13 { text.parse().map_err(|_| (StatusCode::BAD_REQUEST, "digits only")) } else { Err((StatusCode::BAD_REQUEST, "13 digits")) }
}

#[allow(clippy::result_large_err)] // ErrorResponse holds a whole Response (about 128 bytes)
async fn show(Path(isbn): Path<String>) -> Result<Json<u64>> {
    let isbn = parse_isbn(&isbn)?; // (StatusCode, &str) -> ErrorResponse
    if isbn % 2 == 0 {
        return Err(ErrorResponse::from(StatusCode::NOT_FOUND));
    }
    Ok(Json(isbn))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/books/{isbn}", get(show));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Clippy’s result_large_err lint fires on such handlers because the error variant is a whole Response; that is harmless here, but it is one more reason why production services use a typed AppError so that every failure is logged and mapped consistently (see Error Handling).

Bodies and streaming

axum::body::Body is axum’s single body type: a type-erased http_body::Body of Bytes frames. It can be built from strings and bytes, from any other http_body::Body (Body::new), or from a Stream of Result<impl Into<Bytes>, E> with Body::from_stream. A streamed body is sent with transfer-encoding: chunked (HTTP/1.1) as items arrive, so the handler returns immediately and memory stays flat no matter how large the response is:

use std::convert::Infallible;
use std::time::Duration;

use axum::{Router, body::Body, http::header, response::IntoResponse, routing::get};
use futures::StreamExt;
use tokio_stream::wrappers::IntervalStream;

async fn export_catalogue() -> impl IntoResponse {
    let titles = ["Dune", "The Dispossessed", "Kindred"];
    // one CSV line every 100 ms -- imagine rows streamed from the database
    let rows = IntervalStream::new(tokio::time::interval(Duration::from_millis(100)))
        .zip(futures::stream::iter(titles))
        .map(|(_, title)| Ok::<_, Infallible>(format!("{title}\n")));
    ([(header::CONTENT_TYPE, "text/csv")], Body::from_stream(rows))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/exports/titles.csv", get(export_catalogue));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

If the stream yields an Err, the connection is closed mid-response; the client sees a truncated body, not an error status (the status was already sent). Validate everything that can fail before starting the stream. Streaming files, NDJSON and SSE are covered on Server-Sent Events & Streaming and File Uploads; middleware that needs a concrete body type can use ResponseAxumBodyLayer (0.8.5) to map any body back to Body.

Conditional responses: ETag and If-None-Match

An ETag is a validator for one version of a resource. A client that has a cached copy sends it back in If-None-Match; if the resource has not changed, the server answers 304 Not Modified with no body (RFC 9110, section 13). Computing the tag from the representation’s bytes is the simplest correct approach:

use std::hash::{DefaultHasher, Hash, Hasher};

use axum::{
    Router,
    http::{HeaderMap, StatusCode, header},
    response::{IntoResponse, Response},
    routing::get,
};

fn etag_for(body: &str) -> String {
    let mut hasher = DefaultHasher::new();
    body.hash(&mut hasher);
    format!("\"{:016x}\"", hasher.finish()) // strong validator: quoted, no W/ prefix
}

async fn book(headers: HeaderMap) -> Response {
    let body = r#"{"isbn":"9780441013593","title":"Dune","year":1965}"#;
    let etag = etag_for(body);

    let matches = headers
        .get(header::IF_NONE_MATCH)
        .and_then(|v| v.to_str().ok())
        .is_some_and(|v| v == "*" || v.split(',').any(|t| t.trim().trim_start_matches("W/") == etag));
    if matches {
        return (StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response();
    }
    ([(header::ETAG, etag.as_str()), (header::CONTENT_TYPE, "application/json")], body).into_response()
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/api/v1/books/9780441013593", get(book));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
$ curl -si localhost:3000/api/v1/books/9780441013593 | grep etag
etag: "a799a717c5539914"
$ curl -si -H 'if-none-match: "a799a717c5539914"' localhost:3000/api/v1/books/9780441013593 | head -1
HTTP/1.1 304 Not Modified

DefaultHasher is fine for a demo but its output may change between Rust releases; use a stable hash (such as a SHA-256 prefix or the row’s version column) so tags survive deployments. For writes, the mirror image is If-Match, which prevents lost updates — see JSON REST APIs. Static files get strong ETags from tower-http 0.7’s ServeDir automatically.

Where the books are outdated

Yoon (Beginning Axum, chapter 2) returns StatusCode::NO_CONTENT for deletions; axum 0.8 adds NoContent as a clearer alternative (both are fine). Code written before 0.8.5 may rely on Redirect::to panicking on an invalid URI; it now produces a 500 response instead. Older material builds responses with axum::body::boxed and BoxBody, which were replaced by axum::body::Body in 0.7.