Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 028 / 100

Axum extractors: what every Rust developer should know

Typed request data.

Expona Admin7 min read

Introduction

If you've built web services in Rust, you've likely reached for Axum. The framework's extractors are one of its most powerful features, yet many developers treat them as black boxes—just slotting them into handler functions without understanding what's happening under the hood. Axum extractors: what every Rust developer should know covers the core mechanics that let you pull typed request data into your handlers with minimal boilerplate, and why mastering them makes your code safer and cleaner.

Extractors are Axum's answer to the common web development problem: how do you take raw HTTP request data and turn it into strongly-typed Rust values your handler can use? Rather than manually parsing headers, query strings, and bodies, extractors handle that for you—with full compile-time type checking and automatic error responses when something goes wrong.

Path: extracting URL parameters

URL parameters are often your first interaction with extractors. When you define a route like /users/:id, you need a way to grab that id value and pass it to your handler. The Path extractor does exactly that, and it works with both structs and tuples.

rust
use axum::{
    extract::Path,
    routing::get,
    Router,
};

async fn get_user(Path(id): Path) -> String {
    format!("User ID: {}", id)
}

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

For multiple parameters or clearer semantics, use a struct with serde:

rust
use axum::extract::Path;
use serde::Deserialize;

#[derive(Deserialize)]
struct UserPath {
    id: u32,
    action: String,
}

async fn handle_user(Path(params): Path) -> String {
    format!("User {} action: {}", params.id, params.action)
}

In production, we often see this with database lookups. The parameter extraction happens before your async work, so invalid types are rejected at the HTTP layer—you never waste cycles querying for a user with ID "abc" when the type system already knew it was wrong.

Query: parsing query strings

Query parameters come from the URL's query string (?key=value&key2=value2). The Query extractor deserializes these into a typed struct, making validation and default values painless.

rust
use axum::extract::Query;
use serde::Deserialize;

#[derive(Deserialize)]
struct ListParams {
    limit: Option,
    offset: Option,
    sort_by: Option,
}

async fn list_users(Query(params): Query) -> String {
    let limit = params.limit.unwrap_or(10);
    let offset = params.offset.unwrap_or(0);
    format!("Limit: {}, Offset: {}", limit, offset)
}

One advantage over unstructured approaches: if a client sends an unexpected query param, it's simply ignored. If they omit optional fields, serde fills in Option::None. Serde's #[serde(default)] also works, so you can provide sensible defaults in your struct definition rather than in the handler.

rust
#[derive(Deserialize)]
struct ListParams {
    #[serde(default = "default_limit")]
    limit: i32,
    offset: i32,
}

fn default_limit() -> i32 {
    10
}

In e-commerce systems, we've found this pattern invaluable for pagination and filtering. Clients can build query strings freely, and your handler gets clean, type-checked input—or a 400 Bad Request if deserialization fails.

Json: deserializing request bodies

POST and PUT requests typically carry JSON bodies. The Json extractor parses and validates the body into your struct automatically.

rust
use axum::{
    extract::Json,
    http::StatusCode,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct CreateUserRequest {
    name: String,
    email: String,
}

#[derive(Serialize)]
struct UserResponse {
    id: u32,
    name: String,
    email: String,
}

async fn create_user(
    Json(payload): Json,
) -> (StatusCode, Json) {
    let user = UserResponse {
        id: 1,
        name: payload.name,
        email: payload.email,
    };
    (StatusCode::CREATED, Json(user))
}

If the JSON is malformed or doesn't match your struct's schema, Axum automatically returns a 400 response with a descriptive error message. This saves you from writing validation boilerplate in every handler.

A note on error handling: by default, serde rejects extra fields in JSON. If you want to ignore them, add #[serde(deny_unknown_fields = false)] (though the default behavior is usually safer for API contracts). In production systems handling trading data or payment processing, strict validation has prevented subtle bugs where a client sends a field we weren't expecting—and relying on the struct definition ensures consistency.

State: sharing resources across handlers

Most non-trivial applications need shared state: database connection pools, cache clients, configuration, or metrics collectors. The State extractor lets you inject these cleanly without global variables or context dictionaries.

rust
use axum::{
    extract::State,
    routing::get,
    Router,
};
use std::sync::Arc;

#[derive(Clone)]
struct AppState {
    db_url: String,
    max_connections: u32,
}

async fn get_config(State(state): State>) -> String {
    format!("DB: {}", state.db_url)
}

#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        db_url: "postgres://localhost".to_string(),
        max_connections: 10,
    });
    
    let app = Router::new()
        .route("/config", get(get_config))
        .with_state(state);
    
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    
    axum::serve(listener, app)
        .await
        .unwrap();
}

The type system ensures that your state is thread-safe (it must be Clone) and that you can't accidentally access state that wasn't registered with the router. In our ride-hailing services, we wrap database pools and Redis clients in Arc and pass them via state—each handler can spawn async tasks knowing they have safe access to shared resources.

For more complex applications, you might nest states or use different state types per sub-router:

rust
let user_routes = Router::new()
    .route("/list", get(list_users))
    .with_state(user_db_pool);

let admin_routes = Router::new()
    .route("/metrics", get(admin_metrics))
    .with_state(metrics_client);

let app = Router::new()
    .nest("/users", user_routes)
    .nest("/admin", admin_routes);

Combining extractors

Real handlers often need multiple extractors. Axum lets you compose them in function parameters:

rust
async fn update_user(
    State(db): State>,
    Path(id): Path,
    Query(params): Query,
    Json(payload): Json,
) -> (StatusCode, Json) {
    // All extracted types are available here
    // Extraction happens in order; if any fails, a 400 is returned
    todo!()
}

Extractors are tried in the order they appear. If any extractor fails (invalid JSON, missing path param, etc.), the handler is never called and an appropriate error response is sent. This fail-fast behavior prevents handlers from dealing with partial or invalid data.

Common mistakes

Forgetting to wrap State in Arc: Your state type must be Clone for Axum to share it safely across async tasks. If you try to put a non-Clone type directly in state, you'll hit a compile error. The solution is always to wrap in Arc (or Arc<Mutex<T>> if you need interior mutability).

Mismatching route parameters with Path struct fields: If your route is /users/:user_id/posts/:post_id but your Path struct has fields named id and post_id, deserialization will fail. Field names in the struct must match the segment names in the route.

Ignoring deserialization errors: Don't assume JSON or query params are valid. Axum handles this automatically if you use extractors, but if you manually parse or skip validation, you're opening the door to panics or silent data corruption. Trust the extractor; it will return 400 if something is wrong.

Extracting large request bodies without limits: Axum's Json extractor has a default body size limit, but it's good practice to be explicit about what you're willing to accept. Use DefaultBodyLimit middleware or custom limits in high-traffic scenarios to prevent memory exhaustion attacks.

Key takeaways

  • Extractors give you typed, validated request data automatically. Use them instead of manual parsing—the type system and compile-time checks save debugging time.
  • Path deserializes URL parameters; use a struct with named fields for clarity when you have multiple params.
  • Query parses query strings; Option and #[serde(default)] make optional params ergonomic.
  • Json validates and deserializes request bodies; malformed input is rejected at the HTTP layer before your code runs.
  • State lets you share resources safely via the type system—wrap them in Arc and pass via with_state.
  • Combine multiple extractors in a single handler. They're tried in order; any failure returns a 400 before your handler logic runs.
  • Extractors leverage Rust's type system and serde to eliminate entire categories of bugs that plague dynamically typed web frameworks.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles