Expert guidance for Axum 0.8.x web framework development in Rust.
This skill provides expert guidance for developing with Axum 0.8.x, the ergonomic and modular web framework built with Tokio, Tower, and Hyper. Axum 0.8 was released in January 2025 and includes several breaking changes from 0.7.
Old Syntax (0.7):
Router::new()
.route("/users/:id", get(handler))
.route("/files/*path", get(catch_all))
New Syntax (0.8):
Router::new()
.route("/users/{id}", get(handler))
.route("/files/{*path}", get(catch_all))
Migration:
:param with {param}*param with {*param}Examples:
// Single parameter
.route("/users/{user_id}", get(get_user))
// Multiple parameters
.route("/users/{user_id}/posts/{post_id}", get(get_post))
// Catch-all parameter
.route("/files/{*path}", get(serve_file))
// Extracting in handlers
async fn get_user(Path(user_id): Path<String>) { }
async fn get_post(Path((user_id, post_id)): Path<(String, String)>) { }
async_trait Macro Removal (BREAKING)Rust now has native support for async trait methods (RPITIT - Return Position Impl Trait In Traits), so the #[async_trait] macro is no longer needed.
Migration:
// OLD (0.7)
use axum::async_trait;
#[async_trait]
impl<S> FromRequestParts<S> for MyExtractor
where
S: Send + Sync,
{
type Rejection = MyRejection;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
// ...
}
}
// NEW (0.8)
use async_trait::async_trait; // Use this if you still need it elsewhere
impl<S> FromRequestParts<S> for MyExtractor
where
S: Send + Sync,
{
type Rejection = MyRejection;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Self, Self::Rejection> {
// ...
}
}
Important:
#[async_trait] from custom FromRequestParts and FromRequest implementationsasync_trait for other traits, import it from the async-trait crate directlyasync-trait = "0.1" to your Cargo.toml if neededOption<T> Extractor Behavior Change (BREAKING)Previously, Option<T> would silently swallow ANY rejection and return None. Now it requires T to implement OptionalFromRequestParts or OptionalFromRequest.
Old Behavior (0.7):
// This would ALWAYS succeed, even if token was invalid
async fn handler(user: Option<AuthenticatedUser>) {
match user {
Some(user) => // authenticated
None => // could be missing OR invalid token
}
}
New Behavior (0.8):
// This can now fail if the token is invalid
async fn handler(user: Option<AuthenticatedUser>) -> Result<Response, StatusCode> {
match user {
Some(user) => // authenticated with valid token
None => // missing token (but would return error for invalid token)
}
}
Migration Strategy:
For extractors that should be truly optional (missing = None, invalid = error):
use axum::extract::rejection::OptionalFromRequestPartsError;
impl OptionalFromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = OptionalFromRequestPartsError<MyRejection>;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> Result<Option<Self>, Self::Rejection> {
match Self::from_request_parts(parts, state).await {
Ok(user) => Ok(Some(user)),
Err(rejection) if rejection.is_missing() => Ok(None),
Err(rejection) => Err(OptionalFromRequestPartsError::Inner(rejection)),
}
}
}
For truly optional behavior (ignore all rejections):
// Use Result instead
async fn handler(user: Result<AuthenticatedUser, AuthRejection>) {
match user {
Ok(user) => // authenticated
Err(_) => // missing or invalid
}
}
Multiple Extractors (Order Matters):
async fn handler(
Path(id): Path<String>, // Path first
State(state): State<AppState>, // State second
Query(params): Query<SearchParams>, // Query params
Json(body): Json<CreateRequest>, // Body last
) -> impl IntoResponse {
// ...
}
Optional Parameters:
async fn handler(
Path(id): Path<String>,
pagination: Option<Query<Pagination>>,
) -> impl IntoResponse {
let Query(pagination) = pagination.unwrap_or_default();
// ...
}
Struct-based (Recommended for multiple params):
#[derive(Deserialize)]
struct UserParams {
user_id: Uuid,
team_id: Uuid,
}
async fn handler(Path(UserParams { user_id, team_id }): Path<UserParams>) {
// ...
}
Router::new().route("/users/{user_id}/teams/{team_id}", get(handler))
Tuple-based (Quick for 2-3 params):
async fn handler(Path((user_id, team_id)): Path<(Uuid, Uuid)>) {
// ...
}
HashMap/Vec for dynamic parameters:
use std::collections::HashMap;
async fn handler(Path(params): Path<HashMap<String, String>>) {
// All path parameters as key-value pairs
}
#[derive(Clone)]
struct AppState {
db: PgPool,
redis: RedisClient,
}
let state = AppState { db, redis };
let app = Router::new()
.route("/", get(handler))
.with_state(state);
async fn handler(State(state): State<AppState>) -> impl IntoResponse {
// Access state.db, state.redis
}
Custom Rejection Types:
use axum::{
response::{IntoResponse, Response},
http::StatusCode,
};
struct MyError(anyhow::Error);
impl IntoResponse for MyError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error: {}", self.0),
).into_response()
}
}
impl<E> From<E> for MyError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}
async fn handler() -> Result<Json<Response>, MyError> {
let data = fetch_data().await?;
Ok(Json(data))
}
Cargo.toml:[dependencies]
axum = "0.8"
tokio = { version = "1.0", features = ["full"] }
# Add if you use async_trait elsewhere:
async-trait = "0.1"
Update all route paths:
/: → /{ and add closing }/* → /{*Remove #[async_trait] from extractors:
impl FromRequestParts and impl FromRequest#[async_trait] attributeuse axum::async_trait; to use async_trait::async_trait; if needed elsewhereReview Option<T> extractors:
Option<CustomExtractor>OptionalFromRequestParts if neededTest all routes:
// In 0.8, fallback behavior with nested routers may differ
// Test your fallback routes carefully after migration
let api = Router::new()
.route("/users", get(users))
.fallback(api_fallback);
let app = Router::new()
.nest("/api", api)
.fallback(app_fallback);
// get_service is removed in favor of more specific methods
// OLD: .route("/assets/*path", get_service(ServeDir::new("assets")))
// NEW: Use fallback_service or route_service
.route_service("/assets/*path", ServeDir::new("assets"))
Axum 0.8 works with http-body 1.0. Ensure your body types are compatible.
use validator::Validate;
#[derive(Deserialize, Validate)]
struct SearchQuery {
#[validate(length(min = 1, max = 100))]
q: String,
#[validate(range(min = 1, max = 100))]
limit: Option<u32>,
}
async fn search(Query(query): Query<SearchQuery>) -> Result<Json<Results>, StatusCode> {
query.validate().map_err(|_| StatusCode::BAD_REQUEST)?;
// ...
}
[dependencies]
axum = "0.8"
tokio = { version = "1.0", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Database
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
# Validation
validator = { version = "0.18", features = ["derive"] }
# UUID & Time
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# Tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Use State efficiently:
Arc<T> wrapped stateArc<AppState> directly in your stateAvoid unnecessary cloning:
// Good: Use references where possible
async fn handler(State(state): State<Arc<AppState>>) {
// state is Arc, cloning is cheap
}
Use middleware wisely:
use tower_http::trace::TraceLayer;
let app = Router::new()
.route("/", get(handler))
.layer(TraceLayer::new_for_http());
#[cfg(test)]
mod tests {
use super::*;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
#[tokio::test]
async fn test_route() {
let app = app();
let response = app
.oneshot(
Request::builder()
.uri("/users/123")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}