When your API returns a 500 error with a generic "Internal Server Error" message, your frontend team is left guessing what went wrong. Error handling in Axum: a practical guide for developers shows you how to build consistent, informative error responses that your client applications can actually work with. In this article, we'll walk through building a custom error type, mapping errors to HTTP responses, and keeping sensitive details out of user-facing messages.
Why consistent error handling matters
Imagine supporting an API where some endpoints return error messages as JSON objects, others as plain text, and a few don't include any context at all. Your mobile team writes error handling logic that works for 80% of cases. Your web team does it differently. When a bug surfaces in production, nobody's quite sure whether the error format changed or the API logic broke.
Consistent error responses solve this. When every error follows the same structure—a status code, an error code, a user-facing message, and optional details—your frontend code becomes simpler and more reliable. Your logging becomes predictable. Your monitoring catches patterns. This is what we build toward in error handling in Axum.
Custom error type — An enum for your app errors
Start by defining the errors your application actually encounters. Rather than letting Rust's default errors bubble up, create an enum that captures your domain:
use axum::http::StatusCode;
#[derive(Debug)]
pub enum AppError {
UserNotFound,
InvalidInput(String),
DatabaseError(String),
Unauthorized,
ConflictingResource,
InternalServerError,
}
This enum becomes the single source of truth for what can go wrong in your application. Each variant represents a distinct error condition your code might encounter. When a database query fails, you don't panic or return a generic 500—you return AppError::DatabaseError.
If you're building a user service, your errors might look like:
#[derive(Debug)]
pub enum UserServiceError {
NotFound { user_id: i64 },
EmailAlreadyExists { email: String },
InvalidEmail,
PasswordTooWeak,
DatabaseConnectionFailed,
}
Notice how each variant can carry context. NotFound includes the user ID so your logging knows which resource was missing. EmailAlreadyExists tells you which email caused the conflict. This context is invaluable when debugging production issues.
IntoResponse — Map errors to status codes and JSON
Axum's response system works through the IntoResponse trait. When you return a value from a handler, Axum asks: "Can this become an HTTP response?" By implementing IntoResponse for your custom error type, you define exactly how errors become responses.
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Serialize)]
pub struct ErrorResponse {
pub code: String,
pub message: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match self {
AppError::UserNotFound => (
StatusCode::NOT_FOUND,
"USER_NOT_FOUND",
"The requested user does not exist.",
),
AppError::InvalidInput(ref reason) => (
StatusCode::BAD_REQUEST,
"INVALID_INPUT",
"One or more fields are invalid.",
),
AppError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"Authentication required.",
),
AppError::ConflictingResource => (
StatusCode::CONFLICT,
"CONFLICT",
"A resource with this identifier already exists.",
),
AppError::DatabaseError(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"An unexpected error occurred. Please try again later.",
),
AppError::InternalServerError => (
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"An unexpected error occurred. Please try again later.",
),
};
let body = Json(ErrorResponse {
code: code.to_string(),
message: message.to_string(),
});
(status, body).into_response()
}
}
This implementation is the bridge between your internal error world and the HTTP world. Notice how sensitive information never reaches the client. If your database fails, the client gets "An unexpected error occurred"—not the actual database connection string or query that failed. The HTTP status code follows REST conventions: 404 for not found, 400 for bad input, 500 for server errors.
Your API consumers now have a predictable contract. They know every error response includes a code field they can pattern-match on and a message field for display to users or logging.
? in handlers — Return Result<Json<T>, AppError>
With your error type in place, handlers become clean and readable. Use the ? operator to propagate errors up without ceremony:
use axum::{extract::Path, routing::get, Json, Router};
#[derive(Serialize)]
pub struct User {
pub id: i64,
pub email: String,
pub name: String,
}
async fn get_user(
Path(user_id): Path,
) -> Result, AppError> {
let user = database::find_user_by_id(user_id)
.await
.map_err(|_| AppError::DatabaseError("Failed to query users".to_string()))?;
match user {
Some(u) => Ok(Json(u)),
None => Err(AppError::UserNotFound),
}
}
async fn create_user(
Json(payload): Json,
) -> Result, AppError> {
validate_email(&payload.email)
.map_err(|_| AppError::InvalidInput("Invalid email format".to_string()))?;
let new_user = database::insert_user(&payload)
.await
.map_err(|e| {
if e.contains("duplicate key") {
AppError::ConflictingResource
} else {
AppError::DatabaseError(e)
}
})?;
Ok(Json(new_user))
}
pub fn router() -> Router {
Router::new()
.route("/users/:id", get(get_user))
.route("/users", post(create_user))
}
See how clean the happy path is? Each handler focuses on the business logic. When something fails, you return the appropriate error variant. Axum's IntoResponse implementation handles the rest—status codes, JSON formatting, everything.
The ? operator converts errors from dependencies into your error type. The map_err call translates a database error into AppError::DatabaseError, and the handler immediately returns. No nested if-statements, no manual error construction.
Log details — Log internally, return safe messages
Your IntoResponse implementation hides sensitive details from clients, but you still need to log what actually happened for debugging and monitoring:
use tracing::{error, warn};
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, code, message) = match &self {
AppError::UserNotFound => {
(StatusCode::NOT_FOUND, "USER_NOT_FOUND", "User not found.")
}
AppError::InvalidInput(reason) => {
warn!(reason = reason, "Invalid input provided");
(StatusCode::BAD_REQUEST, "INVALID_INPUT", "Invalid input.")
}
AppError::DatabaseError(reason) => {
error!(reason = reason, "Database operation failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"An unexpected error occurred.",
)
}
AppError::Unauthorized => {
warn!("Unauthorized access attempt");
(StatusCode::UNAUTHORIZED, "UNAUTHORIZED", "Unauthorized.")
}
AppError::ConflictingResource => {
(
StatusCode::CONFLICT,
"CONFLICT",
"Resource already exists.",
)
}
AppError::InternalServerError => {
error!("Internal server error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"An unexpected error occurred.",
)
}
};
let body = Json(ErrorResponse {
code: code.to_string(),
message: message.to_string(),
});
(status, body).into_response()
}
}
Now when a database error occurs, your logs capture the actual connection error, query timeout, or constraint violation. Your monitoring system alerts you. But the client simply gets a polite "An unexpected error occurred."
This separation of concerns—internal logging versus external messaging—is critical for security and maintainability. You're not leaking implementation details to potential attackers. You're not giving external APIs information about your database schema. Yet you retain full visibility into what's happening in production.
Common mistakes
Overly specific error types: If you define an error variant for every single failure condition, your error enum becomes impossible to maintain. Group related errors: ValidationError(Vec<String>) instead of separate variants for each field.
Logging sensitive data: Don't log user passwords, API keys, or personally identifiable information. Use the error details for debugging infrastructure problems, not user data.
Inconsistent status codes: Always use the right HTTP status code. A validation error is 400, not 500. A missing resource is 404, not 500. Your monitoring and client logic depend on this.
Generic error messages: "Error" tells your frontend nothing. "Invalid email format" tells them exactly what to fix and show the user.
Forgetting error context: When you convert a database error to your error type, preserve context. Which user? Which query? Include enough information in your error variant that you can debug without rerunning requests.
Key takeaways
- Define a custom error enum that captures the real failure modes in your application domain.
- Implement
IntoResponseto map each error variant to an HTTP status code and a JSON response structure. - Use
Result<Json<T>, AppError>return types in handlers so the?operator works naturally. - Log the full error details internally, but return sanitized, user-friendly messages to clients.
- Keep your error codes consistent so frontend code can reliably handle different failure modes.
- Group related errors into fewer variants rather than creating an error for every edge case.






Comments
Loading comments…