How does axum::extract::rejection types enable customizing error responses for extraction failures?

Axum's extract::rejection module provides concrete rejection types that implement IntoResponse, allowing you to intercept extraction failures and transform them into custom HTTP error responses by implementing your own extractors or using rejection handlers. These types represent specific failure modes (like invalid JSON, missing headers, or path parameter mismatches) and carry relevant information about what went wrong.

What Are Extractor Rejections

use axum::{
    extract::{Json, Path, Query},
    http::StatusCode,
    response::IntoResponse,
};
use serde::Deserialize;
 
// When an extractor fails, it "rejects" the request
// This rejection becomes an HTTP response
 
#[derive(Deserialize)]
struct UserParams {
    id: u64,
}
 
async fn get_user(Path(id): Path<u64>) -> impl IntoResponse {
    // If path extraction fails, Axum returns a rejection response
    // The default is often 404 or 400 with minimal information
    format!("User ID: {}", id)
}
 
// Rejections happen when:
// - Path parameter doesn't match expected type (e.g., "abc" for u64)
// - JSON body fails to parse
// - Query parameters are missing or invalid
// - Headers are missing or malformed
// - Body exceeds size limits

When extractors fail, they reject the request with an HTTP error response.

Default Rejection Responses

use axum::{
    extract::{Json, Path},
    http::StatusCode,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct UserData {
    name: String,
    age: u32,
}
 
// Default rejection responses are often minimal:
// - Path extraction failure: 404 Not Found
// - JSON parse failure: 400 Bad Request
// - Query parse failure: 400 Bad Request
// - Missing required field: 400 Bad Request
//
// The body often contains minimal or no useful error information
 
async fn create_user(Json(user): Json<UserData>) -> impl IntoResponse {
    // If JSON parsing fails, Axum returns 400 Bad Request
    // with minimal information about what went wrong
    format!("Created user: {}", user.name)
}
 
async fn get_user_id(Path(id): Path<u64>) -> impl IntoResponse {
    // If path contains "/users/abc" instead of "/users/123",
    // Axum returns 404 Not Found by default
    format!("User: {}", id)
}

Default rejections provide status codes but often lack detailed error information.

The Rejection Types

// axum::extract::rejection provides specific rejection types:
 
use axum::extract::rejection::{
    JsonRejection,           // JSON body extraction failed
    PathRejection,           // Path parameter extraction failed
    QueryRejection,          // Query parameter extraction failed
    FormRejection,           // Form data extraction failed
    ExtensionRejection,      // Extension extraction failed
    MissingExtension,        // Extension not found
    HeadersAlreadyExtracted, // Headers extracted twice
    BodyAlreadyExtracted,    // Body extracted twice
};
 
// Each rejection type contains relevant error information
 
fn example_rejection_types() {
    // JsonRejection variants:
    // - JsonDataError: JSON didn't match expected structure
    // - JsonSyntaxError: Invalid JSON syntax
    // - MissingJsonContentType: Content-Type header missing
    
    // PathRejection variants:
    // - FailedToDeserializePath: Path parameter couldn't parse
    
    // QueryRejection variants:
    // - FailedToDeserializeQueryString: Query params couldn't parse
}

Each rejection type contains specific information about the failure mode.

Customizing Rejection Responses

use axum::{
    extract::{Json, rejection::JsonRejection},
    http::StatusCode,
    response::IntoResponse,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct User {
    name: String,
    age: u32,
}
 
// Method 1: Handle the Result explicitly
async fn create_user(
    result: Result<Json<User>, JsonRejection>,
) -> impl IntoResponse {
    match result {
        Ok(Json(user)) => {
            (StatusCode::CREATED, format!("Created: {}", user.name))
        }
        Err(rejection) => {
            // Convert rejection to custom error response
            let status = rejection.status();
            let message = rejection.body_text();
            
            (
                status,
                Json(serde_json::json!({
                    "error": "invalid_request",
                    "message": message,
                    "status": status.as_u16(),
                }))
            )
        }
    }
}

You can intercept the Result<Extractor, Rejection> and customize the response.

Implementing Custom Extractors

use axum::{
    async_trait,
    extract::{FromRequest, FromRequestParts, rejection::JsonRejection},
    http::{Request, StatusCode},
    response::IntoResponse,
    Json,
};
use serde::de::DeserializeOwned;
 
// Custom JSON extractor with better error messages
struct MyJson<T>(pub T);
 
#[async_trait]
impl<S, T> FromRequest<S> for MyJson<T>
where
    S: Send + Sync,
    T: DeserializeOwned,
{
    type Rejection = JsonError;
 
    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        // Try to extract JSON
        match Json::<T>::from_request(req, state).await {
            Ok(Json(value)) => Ok(MyJson(value)),
            Err(rejection) => {
                // Convert to custom error
                Err(JsonError::from_rejection(rejection))
            }
        }
    }
}
 
#[derive(Debug)]
struct JsonError {
    message: String,
    status: StatusCode,
}
 
impl JsonError {
    fn from_rejection(rejection: JsonRejection) -> Self {
        let status = rejection.status();
        let message = rejection.body_text();
        
        // Customize the message based on rejection type
        let message = match rejection {
            JsonRejection::JsonDataError(err) => {
                format!("Invalid JSON data: {}", err)
            }
            JsonRejection::JsonSyntaxError(err) => {
                format!("JSON syntax error: {}", err)
            }
            JsonRejection::MissingJsonContentType => {
                "Content-Type header must be application/json".to_string()
            }
            _ => message,
        };
        
        Self { message, status }
    }
}
 
impl IntoResponse for JsonError {
    fn into_response(self) -> axum::response::Response {
        (
            self.status,
            Json(serde_json::json!({
                "error": "json_parse_error",
                "message": self.message,
            }))
        ).into_response()
    }
}
 
// Use the custom extractor
async fn create_user(MyJson(user): MyJson<User>) -> impl IntoResponse {
    // If JSON parsing fails, our custom error response is returned
    (StatusCode::CREATED, format!("Created: {}", user.name))
}
 
#[derive(serde::Deserialize)]
struct User {
    name: String,
    age: u32,
}

Custom extractors let you control both the success and error responses.

Customizing Path Rejections

use axum::{
    async_trait,
    extract::{FromRequestParts, Path, rejection::PathRejection},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use serde::de::DeserializeOwned;
 
// Custom Path extractor with better errors
struct MyPath<T>(pub T);
 
#[async_trait]
impl<S, T> FromRequestParts<S> for MyPath<T>
where
    S: Send + Sync,
    T: DeserializeOwned,
{
    type Rejection = PathError;
 
    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        match Path::<T>::from_request_parts(parts, state).await {
            Ok(Path(value)) => Ok(MyPath(value)),
            Err(rejection) => Err(PathError::from_rejection(rejection)),
        }
    }
}
 
#[derive(Debug)]
struct PathError {
    message: String,
}
 
impl PathError {
    fn from_rejection(rejection: PathRejection) -> Self {
        let message = match rejection {
            PathRejection::FailedToDeserializePathParams(err) => {
                format!("Invalid path parameters: {}", err)
            }
            PathRejection::MissingPathParams => {
                "Path parameters not found".to_string()
            }
            _ => rejection.body_text(),
        };
        
        Self { message }
    }
}
 
impl IntoResponse for PathError {
    fn into_response(self) -> axum::response::Response {
        (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({
                "error": "invalid_path",
                "message": self.message,
            }))
        ).into_response()
    }
}
 
// Usage
async fn get_user(MyPath(id): MyPath<u64>) -> impl IntoResponse {
    format!("User: {}", id)
}

Path extraction failures can provide specific error messages about which parameter failed.

Customizing Query Rejections

use axum::{
    async_trait,
    extract::{FromRequestParts, Query, rejection::QueryRejection},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use serde::de::DeserializeOwned;
 
struct MyQuery<T>(pub T);
 
#[async_trait]
impl<S, T> FromRequestParts<S> for MyQuery<T>
where
    S: Send + Sync,
    T: DeserializeOwned,
{
    type Rejection = QueryError;
 
    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        match Query::<T>::from_request_parts(parts, state).await {
            Ok(Query(value)) => Ok(MyQuery(value)),
            Err(rejection) => Err(QueryError::from_rejection(rejection)),
        }
    }
}
 
#[derive(Debug)]
struct QueryError {
    message: String,
}
 
impl QueryError {
    fn from_rejection(rejection: QueryRejection) -> Self {
        let message = match rejection {
            QueryRejection::FailedToDeserializeQueryString(err) => {
                // Parse the error to provide more helpful messages
                format!("Invalid query parameters: {}", err)
            }
            _ => rejection.body_text(),
        };
        
        Self { message }
    }
}
 
impl IntoResponse for QueryError {
    fn into_response(self) -> axum::response::Response {
        (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({
                "error": "invalid_query",
                "message": self.message,
            }))
        ).into_response()
    }
}
 
#[derive(serde::Deserialize)]
struct Pagination {
    page: u32,
    per_page: u32,
}
 
async fn list_items(MyQuery(pagination): MyQuery<Pagination>) -> impl IntoResponse {
    format!("Page {} with {} items", pagination.page, pagination.per_page)
}

Query parameter errors can tell users exactly which parameter failed.

Using Extension Rejections

use axum::{
    async_trait,
    extract::{FromRequestParts, Extension},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use std::sync::Arc;
 
struct Database {
    // Database connection pool
}
 
struct AppState {
    db: Arc<Database>,
}
 
// Custom Extension extractor with better error
struct AppExtension<T>(pub T);
 
#[async_trait]
impl<S, T> FromRequestParts<S> for AppExtension<T>
where
    S: Send + Sync,
    T: Clone + Send + Sync + 'static,
{
    type Rejection = ExtensionError;
 
    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        match Extension::<T>::from_request_parts(parts, state).await {
            Ok(Extension(value)) => Ok(AppExtension(value)),
            Err(rejection) => Err(ExtensionError {
                type_name: std::any::type_name::<T>(),
            }),
        }
    }
}
 
#[derive(Debug)]
struct ExtensionError {
    type_name: &'static str,
}
 
impl IntoResponse for ExtensionError {
    fn into_response(self) -> axum::response::Response {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({
                "error": "internal_error",
                "message": format!("Missing extension: {}", self.type_name),
            }))
        ).into_response()
    }
}
 
// Usage
async fn get_data(
    AppExtension(state): AppExtension<Arc<AppState>>
) -> impl IntoResponse {
    // Use state.db...
    "data"
}

Extension rejections indicate missing application state (server misconfiguration).

Combining Multiple Custom Extractors

use axum::{
    async_trait,
    extract::{FromRequest, FromRequestParts, rejection},
    http::{Request, StatusCode},
    response::IntoResponse,
    Json,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct CreateUserRequest {
    name: String,
    email: String,
}
 
// Unified error type for all rejections
enum ApiError {
    Json(JsonError),
    Path(PathError),
    Query(QueryError),
}
 
impl From<JsonError> for ApiError {
    fn from(err: JsonError) -> Self {
        ApiError::Json(err)
    }
}
 
impl From<PathError> for ApiError {
    fn from(err: PathError) -> Self {
        ApiError::Path(err)
    }
}
 
impl From<QueryError> for ApiError {
    fn from(err: QueryError) -> Self {
        ApiError::Query(err)
    }
}
 
impl IntoResponse for ApiError {
    fn into_response(self) -> axum::response::Response {
        match self {
            ApiError::Json(err) => err.into_response(),
            ApiError::Path(err) => err.into_response(),
            ApiError::Query(err) => err.into_response(),
        }
    }
}
 
// Handler using multiple custom extractors
async fn create_user(
    MyPath(org_id): MyPath<u64>,
    MyQuery(params): MyQuery<Pagination>,
    MyJson(user): MyJson<CreateUserRequest>,
) -> Result<impl IntoResponse, ApiError> {
    // If any extractor fails, our custom error is returned
    Ok((
        StatusCode::CREATED,
        Json(serde_json::json!({
            "org_id": org_id,
            "user": user,
        }))
    ))
}
 
#[derive(serde::Deserialize)]
struct Pagination {
    page: Option<u32>,
}

A unified error type can combine different rejection types into consistent responses.

The Default Rejection Trait

use axum::{
    extract::rejection::*,
    http::StatusCode,
    response::IntoResponse,
};
 
// All rejection types implement IntoResponse
// They also implement Display and Error
 
fn rejection_status_codes() {
    // JsonRejection status codes
    // - JsonSyntaxError: 400 Bad Request
    // - JsonDataError: 400 Bad Request
    // - MissingJsonContentType: 415 Unsupported Media Type
    
    // PathRejection status codes
    // - FailedToDeserializePathParams: 400 Bad Request
    // - MissingPathParams: 500 Internal Server Error (router bug)
    
    // QueryRejection status codes
    // - FailedToDeserializeQueryString: 400 Bad Request
}
 
// You can access status() and body_text() methods
fn handle_rejection(rejection: JsonRejection) -> impl IntoResponse {
    let status = rejection.status();
    let body = rejection.body_text();
    
    // Create custom response
    (
        status,
        format!("Error ({}): {}", status.as_u16(), body)
    )
}

Each rejection type has appropriate status codes and body text.

Using axum-extra for Better Defaults

// axum-extra provides extractors with better error messages
use axum_extra::extract::{
    // Query with better error handling
    Query,
    // Form with better error handling
    Form,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct SearchParams {
    q: String,
    limit: Option<u32>,
}
 
// axum_extra::extract::Query gives better error messages by default
async fn search(Query(params): Query<SearchParams>) -> impl IntoResponse {
    format!("Searching for: {}", params.q)
}
 
// axum-extra also provides a with rejection pattern
use axum_extra::extract::WithRejection;
 
async fn better_errors(
    // WithRejection wraps an extractor and converts its rejection
    WithRejection(Json(user), _): WithRejection<Json<User>, ApiError>,
) -> impl IntoResponse {
    format!("User: {}", user.name)
}
 
#[derive(serde::Deserialize)]
struct User {
    name: String,
}
 
enum ApiError {
    JsonError,
}
 
impl IntoResponse for ApiError {
    fn into_response(self) -> axum::response::Response {
        (
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({
                "error": "invalid_json",
                "message": "Invalid JSON in request body",
            }))
        ).into_response()
    }
}

axum-extra provides improved extractors and the WithRejection wrapper.

Real-World Error Response Pattern

use axum::{
    async_trait,
    extract::{FromRequest, FromRequestParts, rejection},
    http::{Request, StatusCode},
    response::IntoResponse,
    Json,
};
use serde::Serialize;
use std::fmt;
 
#[derive(Debug)]
pub enum Error {
    Validation { field: String, message: String },
    Json { message: String },
    Path { message: String },
    Query { message: String },
    Internal { message: String },
}
 
impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Error::Validation { field, message } => {
                write!(f, "Validation error on '{}': {}", field, message)
            }
            Error::Json { message } => write!(f, "JSON error: {}", message),
            Error::Path { message } => write!(f, "Path error: {}", message),
            Error::Query { message } => write!(f, "Query error: {}", message),
            Error::Internal { message } => write!(f, "Internal error: {}", message),
        }
    }
}
 
impl IntoResponse for Error {
    fn into_response(self) -> axum::response::Response {
        let (status, error_code, message) = match self {
            Error::Validation { field, message } => {
                (StatusCode::BAD_REQUEST, "validation_error", format!("{}: {}", field, message))
            }
            Error::Json { message } => {
                (StatusCode::BAD_REQUEST, "json_error", message)
            }
            Error::Path { message } => {
                (StatusCode::BAD_REQUEST, "path_error", message)
            }
            Error::Query { message } => {
                (StatusCode::BAD_REQUEST, "query_error", message)
            }
            Error::Internal { message } => {
                (StatusCode::INTERNAL_SERVER_ERROR, "internal_error", message)
            }
        };
 
        (
            status,
            Json(serde_json::json!({
                "error": error_code,
                "message": message,
            }))
        ).into_response()
    }
}
 
// Convenience type alias
pub type Result<T> = std::result::Result<T, Error>;
 
// Custom extractors that use our Error type
pub struct ApiJson<T>(pub T);
 
#[async_trait]
impl<S, T> FromRequest<S> for ApiJson<T>
where
    S: Send + Sync,
    T: serde::de::DeserializeOwned,
{
    type Rejection = Error;
 
    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
        match axum::extract::Json::<T>::from_request(req, state).await {
            Ok(axum::extract::Json(value)) => Ok(ApiJson(value)),
            Err(rejection) => Err(Error::Json {
                message: rejection.body_text(),
            }),
        }
    }
}
 
// Handler using the custom system
#[derive(serde::Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}
 
async fn create_user(
    ApiJson(user): ApiJson<CreateUser>,
) -> Result<impl IntoResponse, Error> {
    // Validate
    if user.name.is_empty() {
        return Err(Error::Validation {
            field: "name".to_string(),
            message: "Name cannot be empty".to_string(),
        });
    }
 
    Ok((
        StatusCode::CREATED,
        Json(serde_json::json!({
            "id": 1,
            "name": user.name,
            "email": user.email,
        }))
    ))
}

A unified error system provides consistent API responses across all extraction failures.

Summary Table

fn summary() {
    // | Rejection Type          | Status Code         | Common Cause                    |
    // |--------------------------|--------------------|----------------------------------|
    // | JsonRejection            | 400/415            | Invalid JSON or missing header   |
    // | PathRejection            | 400/404/500        | Path param type mismatch         |
    // | QueryRejection           | 400                | Invalid query parameters        |
    // | FormRejection            | 400                | Invalid form data               |
    // | ExtensionRejection       | 500                | Missing extension (server bug)  |
    // | MissingExtension         | 500                | Extension not added to app      |
    
    // Customization approaches:
    // 1. Handle Result<Extractor, Rejection> explicitly
    // 2. Implement custom extractors with custom Rejection types
    // 3. Use axum-extra::extract::WithRejection
    // 4. Create unified error types for consistency
}

Synthesis

Quick reference:

use axum::{
    extract::{Json, rejection::JsonRejection},
    http::StatusCode,
    response::IntoResponse,
    Json as JsonResponse,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct User {
    name: String,
}
 
// Default: minimal error response
async fn default_handler(Json(user): Json<User>) -> impl IntoResponse {
    format!("Hello {}", user.name)
}
 
// Custom: detailed error response
async fn custom_handler(
    result: Result<Json<User>, JsonRejection>,
) -> impl IntoResponse {
    match result {
        Ok(Json(user)) => {
            (StatusCode::OK, JsonResponse(user))
        }
        Err(rejection) => {
            let status = rejection.status();
            (
                status,
                JsonResponse(serde_json::json!({
                    "error": "invalid_request",
                    "message": rejection.body_text(),
                }))
            )
        }
    }
}

Key insight: Axum's rejection types serve as the bridge between extractor failures and HTTP responses. Every extractor returns Result<T, Rejection> where Rejection: IntoResponse. The default implementations provide status codes but often minimal, unhelpful error bodies. The axum::extract::rejection module exposes the specific rejection types (JsonRejection, PathRejection, QueryRejection, etc.) so you can intercept them and create better error responses. The most common approach is implementing custom extractors that wrap the standard ones and convert rejections to your own error types—this gives you full control over the error response format while keeping the extractor pattern clean. For API servers, this typically means creating a unified error type that all extractors convert to, ensuring clients always receive consistent JSON error objects with helpful messages about what went wrong, whether it's a JSON syntax error, missing field, type mismatch, or validation failure.