Axum is a web framework for Rust built on top of Tokio and Hyper. One of its
standout features is a simple and predictable error handling model — once
you understand the pattern, writing safe, expressive web servers becomes
surprisingly natural.
This guide walks you from the very basics to real-world patterns you'll use in
production.
The Core Idea: Errors Must Become Responses
In Axum, every error must eventually become an HTTP response. There is no
unhandled-exception mechanism. If something goes wrong, you decide what the
client sees — a 404, a 500, a JSON error body, or anything else.
The key trait is IntoResponse. Anything that implements it can be returned
from a handler, including error types.
1. The Simplest Error: Return a StatusCode
The easiest way to signal an error is to return a StatusCode directly.
use axum::{
extract::Path,
http::StatusCode,
routing::get,
Router,
};
async fn get_user(Path(id): Path<u32>) -> Result<String, StatusCode> {
if id == 0 {
return Err(StatusCode::NOT_FOUND);
}
Ok(format!("User #{}", id))
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users/{id}", get(get_user)); // ✅ use {id}
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
Here the handler returns Result<String, StatusCode>. When Ok, Axum sends
the string as a 200 OK. When Err, it sends the status code you chose. Clean
and simple!
2. Returning Richer Errors with Tuples
A bare StatusCode doesn't tell the client why something went wrong. You can
pair a status code with a message using a tuple.
use axum::{http::StatusCode, routing::post, Json, Router};
use serde::Deserialize;
#[derive(Deserialize)]
struct CreateUser {
username: String,
}
async fn create_user(
Json(payload): Json<CreateUser>,
) -> Result<String, (StatusCode, String)> {
if payload.username.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Username cannot be empty".to_string(),
));
}
Ok(format!("Created user: {}", payload.username))
}
The tuple (StatusCode, String) is automatically turned into a response with
the given status and a text body. Axum knows how to convert it thanks to the
IntoResponse trait.
3. Building a Custom Error Type (The Right Way)
For real applications, you want a single error enum that covers all the things
that can go wrong. Then you implement IntoResponse for it so Axum can turn
your errors into HTTP responses automatically.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Router,
};
// Your application's error type
enum AppError {
NotFound(String),
Unauthorized,
InternalError(String),
}
// Teach Axum how to turn your error into a response
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"You are not authorized".to_string(),
),
AppError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
};
(status, message).into_response()
}
}
// Now use it in your handlers
async fn get_document(Path(id): Path<u32>) -> Result<String, AppError> {
if id == 99 {
return Err(AppError::Unauthorized);
}
if id > 100 {
return Err(AppError::NotFound(format!("Document {} not found", id)));
}
Ok(format!("Document #{}", id))
}
This is the pattern you'll use most often. One error type, one place to define
what each error looks like to clients.
4. Returning JSON Errors
APIs usually return JSON error bodies instead of plain text. Here's how to
return structured JSON from your error type.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Serialize)]
struct ErrorBody {
error: String,
code: u16,
}
enum ApiError {
NotFound(String),
BadRequest(String),
Internal,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
ApiError::Internal => (
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong".to_string(),
),
};
let body = Json(ErrorBody {
error: message,
code: status.as_u16(),
});
(status, body).into_response()
}
}
Now your clients get responses like:
{
"error": "Document 999 not found",
"code": 404
}
5. Converting ? Errors with From
In real code, you'll use ? to propagate errors from libraries like database
drivers or HTTP clients. You can implement From<SomeError> for AppError to
make this seamless.
use std::num::ParseIntError;
enum AppError {
BadRequest(String),
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
// ... (same as above)
}
}
// Convert ParseIntError into AppError automatically
impl From<ParseIntError> for AppError {
fn from(err: ParseIntError) -> Self {
AppError::BadRequest(format!("Invalid number: {}", err))
}
}
async fn parse_id(Path(raw): Path<String>) -> Result<String, AppError> {
// The `?` operator automatically calls From<ParseIntError> here
let id: u32 = raw.parse()?;
Ok(format!("Parsed id: {}", id))
}
The ? after raw.parse() will, on failure, call your From impl and return
an Err(AppError::BadRequest(...)) — no manual conversion needed.
6. Using anyhow for Quick Prototyping
The anyhow crate is popular for applications where you don't need fine-grained
error types yet. You can bridge it into Axum with a small wrapper.
use anyhow::Context;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
// A wrapper so we can impl IntoResponse for anyhow::Error
struct AnyhowError(anyhow::Error);
impl IntoResponse for AnyhowError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Internal error: {}", self.0),
)
.into_response()
}
}
// Allow using `?` on anyhow::Result in handlers
impl<E: Into<anyhow::Error>> From<E> for AnyhowError {
fn from(err: E) -> Self {
AnyhowError(err.into())
}
}
async fn risky_handler() -> Result<String, AnyhowError> {
let data = std::fs::read_to_string("config.txt")
.context("Failed to read config file")?;
Ok(data)
}
7. Handler-Level Error Handling with HandleError
Sometimes you use a Tower Service or middleware that returns errors Axum
doesn't know how to handle. The HandleError layer lets you convert those.
use axum::{
error_handling::HandleError,
http::StatusCode,
routing::get,
Router,
};
use std::convert::Infallible;
async fn might_fail() -> Result<String, String> {
Err("Something broke".to_string())
}
// Wrap the service to handle its errors
let service = tower::service_fn(|_req| async {
Ok::<_, Infallible>("ok")
});
let handled = HandleError::new(service, |err: String| async move {
(StatusCode::INTERNAL_SERVER_ERROR, err)
});
This is more advanced and mainly relevant when integrating third-party Tower
services.
Quick Reference
| Pattern | Best For |
|---|---|
Result<T, StatusCode> |
Simple, code-only errors |
Result<T, (StatusCode, String)> |
Errors with a message |
Custom enum + impl IntoResponse
|
Production apps with typed errors |
impl IntoResponse returning Json
|
APIs that return JSON error bodies |
impl From<LibError> for AppError |
Using ? with library errors |
anyhow wrapper |
Quick prototypes or scripts |
Summary
Axum's error handling boils down to one rule: your errors must implement
IntoResponse. Once they do, you can use Result<T, YourError> in any
handler and Axum handles the rest.
The recommended path for most apps:
- Define an
enum AppErrorwith variants for each failure mode. - Implement
IntoResponsefor it (returning JSON is usually best). - Implement
From<LibError> for AppErrorfor each third-party error type you use with?. - Return
Result<impl IntoResponse, AppError>from your handlers.
That's it. No magic, no hidden panics — just clean, explicit error paths.
Top comments (0)