What is the difference between axum::extract::Path and axum::extract::Query for extracting request parameters?

axum::extract::Path extracts parameters from the URL path segments (like /users/:id), while axum::extract::Query extracts parameters from the URL query string (like ?page=1&sort=desc). The key difference is location: path parameters are part of the route structure and are required by default, whereas query parameters are optional key-value pairs appended after the ? in the URL. Path parameters define the resource being accessed, query parameters modify how the resource is returned or filtered. Both use serde for deserialization, but they differ in requiredness semantics, validation behavior, and typical use cases.

Basic Path Extraction

use axum::{
    extract::Path,
    routing::get,
    Router,
};
use serde::Deserialize;
 
async fn get_user(Path(user_id): Path<u32>) -> String {
    format!("User ID: {}", user_id)
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users/:user_id", get(get_user));
    
    // GET /users/42 -> "User ID: 42"
    // GET /users/abc -> 400 Bad Request (failed to parse u32)
    // GET /users -> 404 Not Found (route doesn't match)
}

Path extracts parameters defined in the route with :param_name syntax.

Basic Query Extraction

use axum::{
    extract::Query,
    routing::get,
    Router,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct Pagination {
    page: Option<u32>,
    per_page: Option<u32>,
}
 
async fn list_users(Query(params): Query<Pagination>) -> String {
    let page = params.page.unwrap_or(1);
    let per_page = params.per_page.unwrap_or(10);
    format!("Page {} with {} items", page, per_page)
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users", get(list_users));
    
    // GET /users -> "Page 1 with 10 items" (defaults)
    // GET /users?page=2 -> "Page 2 with 10 items"
    // GET /users?page=2&per_page=50 -> "Page 2 with 50 items"
    // GET /users?page=abc -> 400 Bad Request (failed to parse)
}

Query extracts optional parameters from the query string.

Multiple Path Parameters

use axum::extract::Path;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct UserPostPath {
    user_id: u32,
    post_id: u32,
}
 
async fn get_user_post(Path(params): Path<UserPostPath>) -> String {
    format!("User {} Post {}", params.user_id, params.post_id)
}
 
// Alternative: tuple extraction
async fn get_user_post_alt(Path((user_id, post_id)): Path<(u32, u32)>) -> String {
    format!("User {} Post {}", user_id, post_id)
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users/:user_id/posts/:post_id", get(get_user_post));
    
    // GET /users/1/posts/42 -> "User 1 Post 42"
}

Multiple path parameters can be extracted as a struct or tuple.

Complex Query Parameters

use axum::extract::Query;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct SearchParams {
    q: String,              // Required
    page: Option<u32>,      // Optional
    sort: Option<String>,   // Optional
    #[serde(default)]
    tags: Vec<String>,      // Default (empty vec)
    #[serde(rename = "per_page")]
    per_page: Option<u32>,  // Renamed field
}
 
async fn search(Query(params): Query<SearchParams>) -> String {
    let page = params.page.unwrap_or(1);
    let sort = params.sort.unwrap_or_else(|| "relevance".to_string());
    
    format!(
        "Search '{}' on page {}, sorted by {}, tags: {:?}",
        params.q, page, sort, params.tags
    )
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/search", get(search));
    
    // GET /search?q=rust -> "Search 'rust' on page 1, sorted by relevance, tags: []"
    // GET /search?q=rust&page=2&sort=date -> "Search 'rust' on page 2, sorted by date, tags: []"
    // GET /search?q=rust&tags=new&tags=popular -> tags: ["new", "popular"]
    // GET /search -> 400 Bad Request (missing required 'q')
}

Query parameters support required fields, optional fields, defaults, and renaming.

Path Parameters Are Required

use axum::extract::Path;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct RequiredPath {
    id: u32,  // Always required - must exist in route
}
 
async fn get_item(Path(params): Path<RequiredPath>) -> String {
    format!("Item {}", params.id)
}
 
// Path parameters cannot be optional:
// #[derive(Deserialize)]
// struct OptionalPath {
//     id: Option<u32>,  // This doesn't make sense for path params
// }
 
// The route defines what exists: /items/:id
// If the route matches, the parameter MUST exist
// If it doesn't exist, the route doesn't match (404)

Path parameters are inherently required by route definition.

Query Parameters Can Be Required

use axum::extract::Query;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct RequiredQuery {
    api_key: String,  // Required - will 400 if missing
}
 
#[derive(Deserialize)]
struct OptionalQuery {
    api_key: Option<String>,  // Optional - will be None if missing
}
 
async fn with_required(Query(params): Query<RequiredQuery>) -> String {
    format!("API Key: {}", params.api_key)
}
 
async fn with_optional(Query(params): Query<OptionalQuery>) -> String {
    match params.api_key {
        Some(key) => format!("API Key: {}", key),
        None => "No API Key provided".to_string(),
    }
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/required", get(with_required))
        .route("/optional", get(with_optional));
    
    // GET /required -> 400 Bad Request
    // GET /required?api_key=secret -> "API Key: secret"
    
    // GET /optional -> "No API Key provided"
    // GET /optional?api_key=secret -> "API Key: secret"
}

Query parameter requiredness is controlled by field type.

Deserialization Errors

use axum::extract::{Path, Query};
use axum::http::StatusCode;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct NumericPath {
    id: u32,
}
 
#[derive(Deserialize)]
struct NumericQuery {
    id: u32,
}
 
async fn path_handler(Path(params): Path<NumericPath>) -> String {
    format!("Path ID: {}", params.id)
}
 
async fn query_handler(Query(params): Query<NumericQuery>) -> Result<String, StatusCode> {
    Ok(format!("Query ID: {}", params.id))
}
 
// Both handlers return 400 Bad Request if parsing fails:
// GET /users/abc -> 400 (Path: invalid u32)
// GET /users?id=abc -> 400 (Query: invalid u32)
 
// The error messages differ slightly in format
// But both use serde's error system

Both Path and Query return 400 on deserialization failures.

Custom Error Handling

use axum::{
    extract::{Path, Query, rejection::QueryRejection},
    response::{Response, IntoResponse},
    http::StatusCode,
    Json,
};
use serde::Deserialize;
use serde_json::json;
 
#[derive(Deserialize)]
struct UserQuery {
    name: String,
}
 
// Custom extractor with better error messages
struct QueryWithMessage<T>(pub T);
 
#[axum::async_trait]
impl<T: serde::de::DeserializeOwned + Send + Sync + 'static> axum::extract::FromRequest<axum::body::Body> for QueryWithMessage<T> {
    type Rejection = Json<serde_json::Value>;
    
    async fn from_request(
        req: axum::extract::Request,
    ) -> Result<Self, Self::Rejection> {
        let query = Query::<T>::from_request(req).await
            .map_err(|e| {
                Json(json!({
                    "error": "Invalid query parameters",
                    "details": e.body_text()
                }))
            })?;
        Ok(QueryWithMessage(query.0))
    }
}
 
async fn search(Query(params): Query<UserQuery>) -> impl IntoResponse {
    Json(json!({ "message": format!("Hello, {}", params.name) }))
}

Custom extractors can provide better error messages.

Combining Path and Query

use axum::{
    extract::{Path, Query},
    routing::get,
    Router,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct UserPath {
    user_id: u32,
}
 
#[derive(Deserialize)]
struct UserQuery {
    include_posts: Option<bool>,
    fields: Option<String>,
}
 
async fn get_user(
    Path(path): Path<UserPath>,
    Query(query): Query<UserQuery>,
) -> String {
    let include = query.include_posts.unwrap_or(false);
    format!(
        "User {} (include_posts: {})",
        path.user_id, include
    )
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users/:user_id", get(get_user));
    
    // GET /users/42 -> User 42 (include_posts: false)
    // GET /users/42?include_posts=true -> User 42 (include_posts: true)
    // GET /users/42?fields=name,email -> User 42 (include_posts: false)
}

Path and Query can be combined in the same handler.

Path Segments vs Query String

use axum::extract::{Path, Query};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct ResourcePath {
    category: String,
    resource_id: u32,
}
 
#[derive(Deserialize)]
struct FilterQuery {
    sort: Option<String>,
    order: Option<String>,
}
 
async fn list_resources(
    Path(path): Path<ResourcePath>,
    Query(query): Query<FilterQuery>,
) -> String {
    format!(
        "Category: {}, Resource: {}, Sort: {}, Order: {}",
        path.category,
        path.resource_id,
        query.sort.unwrap_or_else(|| "default".to_string()),
        query.order.unwrap_or_else(|| "asc".to_string())
    )
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/:category/:resource_id", get(list_resources));
    
    // URL: /books/42?sort=title&order=desc
    // Path params: category="books", resource_id=42
    // Query params: sort="title", order="desc"
}

Path segments define the resource; query parameters filter or modify.

Semantic Differences

// PATH: Identifies a specific resource
// /users/42          -> User with ID 42
// /posts/1/comments/2 -> Comment 2 on Post 1
// /products/electronics/laptops -> Laptops in electronics category
 
// QUERY: Modifies how the resource is returned
// /users?page=2&limit=10 -> Page 2, 10 per page
// /posts?status=published -> Filter by status
// /products?sort=price&order=asc -> Sort by price ascending
 
// Path parameters should be:
// - Resource identifiers
// - Hierarchical data
// - Required for resource location
 
// Query parameters should be:
// - Filters
// - Pagination
// - Sorting
// - Optional configuration

Path and query have different semantic meanings for REST APIs.

Array and Complex Query Values

use axum::extract::Query;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct ComplexQuery {
    // Arrays: ?tags=rust&tags=programming
    #[serde(default)]
    tags: Vec<String>,
    
    // Nested: ?user.name=alice&user.age=30
    #[serde(default)]
    user: Option<UserFilter>,
    
    // Renamed: ?page-number=5
    #[serde(rename = "page-number")]
    page_number: Option<u32>,
    
    // With default value
    #[serde(default = "default_limit")]
    limit: u32,
}
 
#[derive(Deserialize)]
struct UserFilter {
    name: Option<String>,
    age: Option<u32>,
}
 
fn default_limit() -> u32 { 10 }
 
async fn search(Query(params): Query<ComplexQuery>) -> String {
    format!(
        "Tags: {:?}, Page: {}, Limit: {}",
        params.tags, params.page_number.unwrap_or(1), params.limit
    )
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/search", get(search));
    
    // GET /search?tags=rust&tags=web&page-number=5&limit=20
    // Tags: ["rust", "web"], Page: 5, Limit: 20
}

Query parameters support complex deserialization via serde.

String Path Parameters

use axum::extract::Path;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct StringPath {
    // String - any value works
    name: String,
    
    // With pattern matching in route
    // Router would need: .route("/users/:name", ...)
}
 
async fn get_by_name(Path(params): Path<StringPath>) -> String {
    format!("User name: {}", params.name)
}
 
// Or as a raw string
async fn get_by_name_raw(Path(name): Path<String>) -> String {
    format!("User name: {}", name)
}
 
// Tuple form for multiple strings
async fn get_first_last(Path((first, last)): Path<(String, String)>) -> String {
    format!("{} {}", first, last)
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users/:name", get(get_by_name))
        .route("/first/:first/last/:last", get(get_first_last));
    
    // GET /users/alice -> "User name: alice"
    // GET /first/john/last/doe -> "john doe"
}

String path parameters accept any value that can be URL-decoded.

Path Parameter Constraints

use axum::Router;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct NumericPath {
    id: u32,
}
 
// Axum doesn't enforce regex constraints on path parameters directly
// But you can validate in the handler:
 
async fn get_user(Path(params): Path<NumericPath>) -> Result<String, String> {
    if params.id == 0 {
        return Err("ID must be positive".to_string());
    }
    Ok(format!("User {}", params.id))
}
 
// For more complex validation, use a custom extractor or validate in handler
 
// Route matching by structure:
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users/:id", get(get_user))      // Matches any string
        .route("/users/new", get(get_new_form)); // Exact match for "new"
    
    // Order matters! More specific routes should come first
    // "/users/new" would match "/users/:id" if defined first
}

Path validation happens at deserialization time, not routing time.

Raw Query String Access

use axum::extract::Query;
use std::collections::HashMap;
 
// Access raw query as HashMap
async fn raw_query(Query(params): Query<HashMap<String, String>>) -> String {
    let mut result = String::new();
    for (key, value) in params {
        result.push_str(&format!("{}={}\n", key, value));
    }
    result
}
 
// Duplicate keys: last value wins
// GET /search?tag=a&tag=b -> {"tag": "b"}
 
// For multiple values per key:
#[derive(Deserialize)]
struct MultiValueQuery {
    #[serde(default)]
    tag: Vec<String>,
}
 
async fn multi_values(Query(params): Query<MultiValueQuery>) -> String {
    format!("Tags: {:?}", params.tag)
}
 
// GET /search?tag=a&tag=b -> Tags: ["a", "b"]

HashMap<String, String> gives raw access but loses multi-value support.

Default Values

use axum::extract::Query;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct DefaultsQuery {
    // serde default attribute
    #[serde(default = "default_page")]
    page: u32,
    
    // serde default (uses Default trait)
    #[serde(default)]
    limit: Limit,
    
    // Optional with default in handler
    sort: Option<String>,
}
 
fn default_page() -> u32 { 1 }
 
#[derive(Debug, Default, Deserialize)]
struct Limit {
    value: u32,
}
 
async fn with_defaults(Query(mut params): Query<DefaultsQuery>) -> String {
    // Can also apply defaults in handler
    let sort = params.sort.take().unwrap_or_else(|| "created".to_string());
    
    format!("Page: {}, Sort: {}", params.page, sort)
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/items", get(with_defaults));
    
    // GET /items -> Page: 1, Sort: created
    // GET /items?page=5 -> Page: 5, Sort: created
    // GET /items?sort=updated -> Page: 1, Sort: updated
}

Defaults can be specified via serde attributes or handled in code.

Rejection Handling

use axum::{
    extract::{Path, Query, rejection::{PathRejection, QueryRejection}},
    response::{Response, IntoResponse},
    http::StatusCode,
    Json,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct IdPath {
    id: u32,
}
 
#[derive(Deserialize)]
struct FilterQuery {
    name: Option<String>,
}
 
async fn with_rejection_handling(
    path: Result<Path<IdPath>, PathRejection>,
    query: Result<Query<FilterQuery>, QueryRejection>,
) -> Result<Json<serde_json::Value>, StatusCode> {
    let Path(path) = path.map_err(|_| StatusCode::BAD_REQUEST)?;
    let Query(query) = query.map_err(|_| StatusCode::BAD_REQUEST)?;
    
    Ok(Json(serde_json::json!({
        "id": path.id,
        "name": query.name
    })))
}
 
// Or use Result types in signature:
async fn with_result(
    path: Result<Path<IdPath>, PathRejection>,
) -> Result<String, (StatusCode, String)> {
    let Path(path) = path.map_err(|e| {
        (StatusCode::BAD_REQUEST, format!("Invalid path: {}", e))
    })?;
    
    Ok(format!("ID: {}", path.id))
}

Both extractors can return rejections for custom error handling.

Struct vs Tuple Extraction

use axum::extract::Path;
use serde::Deserialize;
 
#[derive(Deserialize)]
struct UserPostPath {
    user_id: u32,
    post_id: u32,
}
 
// Struct form - named fields
async fn struct_form(Path(params): Path<UserPostPath>) -> String {
    format!("User {}, Post {}", params.user_id, params.post_id)
}
 
// Tuple form - positional
async fn tuple_form(Path((user_id, post_id)): Path<(u32, u32)>) -> String {
    format!("User {}, Post {}", user_id, post_id)
}
 
// Single parameter
async fn single_form(Path(id): Path<u32>) -> String {
    format!("ID: {}", id)
}
 
// String form
async fn string_form(Path(name): Path<String>) -> String {
    format!("Name: {}", name)
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users/:user_id/posts/:post_id", get(struct_form))
        .route("/tuple/:user_id/:post_id", get(tuple_form))
        .route("/single/:id", get(single_form))
        .route("/string/:name", get(string_form));
}

Multiple extraction forms offer flexibility.

Complete Example with Both

use axum::{
    extract::{Path, Query},
    routing::get,
    Router,
};
use serde::Deserialize;
 
#[derive(Deserialize)]
struct UserPath {
    user_id: u32,
}
 
#[derive(Deserialize)]
struct UserQuery {
    include_email: Option<bool>,
    #[serde(default)]
    fields: Vec<String>,
}
 
#[derive(Deserialize)]
struct PostsQuery {
    page: Option<u32>,
    per_page: Option<u32>,
    status: Option<String>,
}
 
async fn get_user(
    Path(path): Path<UserPath>,
    Query(query): Query<UserQuery>,
) -> String {
    let include_email = query.include_email.unwrap_or(false);
    let fields = if query.fields.is_empty() {
        "all".to_string()
    } else {
        query.fields.join(", ")
    };
    
    format!(
        "User {} (email: {}, fields: {})",
        path.user_id, include_email, fields
    )
}
 
async fn list_user_posts(
    Path(path): Path<UserPath>,
    Query(query): Query<PostsQuery>,
) -> String {
    let page = query.page.unwrap_or(1);
    let per_page = query.per_page.unwrap_or(10);
    let status = query.status.unwrap_or_else(|| "published".to_string());
    
    format!(
        "User {} posts: page {}, {} per page, status {}",
        path.user_id, page, per_page, status
    )
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/users/:user_id", get(get_user))
        .route("/users/:user_id/posts", get(list_user_posts));
    
    // GET /users/42 -> User 42 (email: false, fields: all)
    // GET /users/42?include_email=true&fields=name&fields=bio
    //     -> User 42 (email: true, fields: name, bio)
    
    // GET /users/42/posts -> User 42 posts: page 1, 10 per page, status published
    // GET /users/42/posts?page=2&status=draft
    //     -> User 42 posts: page 2, 10 per page, status draft
}

Comparison Table

Aspect Path Query
Location URL segments After ? in URL
Example /users/:id ?page=1&sort=desc
Requiredness Always required Controlled by field type
Semantics Resource identity Resource filtering/modification
Typical use IDs, categories Pagination, search, options
Extraction Path<T> Query<T>
Missing param Route doesn't match (404) Field is None or default
Parse failure 400 Bad Request 400 Bad Request
Multiple values Not applicable Vec<T> for repeated params
Optional params Not applicable Option<T> or #[serde(default)]

Synthesis

Path extractors:

  • Extract from URL path segments
  • Define route structure with :param_name
  • Required by default (route won't match without them)
  • Identify specific resources
  • Cannot be optional by design
  • Use struct, tuple, or primitive types

Query extractors:

  • Extract from query string after ?
  • Key-value pairs separated by &
  • Optional by default (use Option<T> or defaults)
  • Filter, paginate, or modify resource access
  • Support arrays and complex nested structures
  • Use serde for deserialization flexibility

When to use Path:

  • Resource identification (user IDs, post slugs)
  • Hierarchical data (categories, subcategories)
  • Required parameters that define the resource
  • Clean, RESTful URLs

When to use Query:

  • Pagination (page, limit, offset)
  • Filtering and searching
  • Sorting options
  • Optional configuration
  • Data that doesn't fit cleanly in path

Key insight: Path parameters are about what resource you're accessing (identity), while query parameters are about how you want it returned (presentation). This separation reflects REST principles: paths define the resource hierarchy, queries modify the representation. A user is identified by path (/users/42) but filtered by query (/users?status=active). The distinction isn't just technical—it's architectural, influencing how clients think about your API and how caching systems (CDNs, browsers) handle requests. Path parameters typically don't have default values because they're required for resource location; query parameters frequently have defaults because they're optional modifiers.