How does axum::Extension state differ from using axum::State for sharing application data?

axum::Extension and axum::State are both mechanisms for sharing application data with handlers, but they differ fundamentally in type safety, performance, and intended use cases. State is the primary, type-safe mechanism where the state type is encoded directly in the router and extracted via State<S> extractors—the router owns the state and handlers receive references with the exact type. Extension uses a type-erased Extensions map internally, storing values as Box<dyn Any> and requiring downcasting at runtime. State should be your default choice for application state; Extension exists primarily for middleware-added data and legacy compatibility. The key trade-off is that State provides compile-time type guarantees and direct references, while Extension offers flexibility at the cost of runtime checks and reference counting.

Basic State Usage

use axum::{
    Router,
    extract::State,
    routing::get,
    response::IntoResponse,
};
use std::sync::Arc;
 
// Define your application state
#[derive(Clone)]
struct AppState {
    db_pool: DbPool,
    config: Config,
    counter: Arc<atomic::AtomicU64>,
}
 
struct DbPool;
struct Config;
 
// Handler receives typed state via State extractor
async fn get_user(State(state): State<AppState>) -> impl IntoResponse {
    // state is AppState, typed and ready to use
    let count = state.counter.load(std::sync::atomic::Ordering::Relaxed);
    format!("Count: {}", count)
}
 
async fn health_check(State(state): State<AppState>) -> impl IntoResponse {
    // Same state type, same direct access
    "OK"
}
 
#[tokio::main]
async fn main() {
    let state = AppState {
        db_pool: DbPool,
        config: Config,
        counter: Arc::new(atomic::AtomicU64::new(0)),
    };
    
    // State is passed to the router with .with_state()
    let app = Router::new()
        .route("/user", get(get_user))
        .route("/health", get(health_check))
        .with_state(state);
    
    // Run the app
    // axum::Server::bind(...).serve(app.into_make_service()).await
}

State provides typed access to application state with compile-time guarantees.

Basic Extension Usage

use axum::{
    Router,
    Extension,
    routing::get,
    response::IntoResponse,
};
use std::sync::Arc;
 
#[derive(Clone)]
struct SharedData {
    value: Arc<String>,
}
 
// Handler receives Extension via Extension extractor
async fn get_data(Extension(data): Extension<SharedData>) -> impl IntoResponse {
    // data is SharedData, extracted from type-erased map
    data.value.clone()
}
 
async fn get_data_ref(Extension(data): Extension<Arc<SharedData>>) -> impl IntoResponse {
    // Can also use Arc directly
    data.value.clone()
}
 
#[tokio::main]
async fn main() {
    let shared = SharedData {
        value: Arc::new("Hello".to_string()),
    };
    
    // Extension is added via .layer()
    let app = Router::new()
        .route("/data", get(get_data))
        .layer(Extension(shared));
    
    // Note: Extension uses tower::ServiceBuilder internally
}

Extension wraps data in a type-erased map and requires runtime extraction.

Type Safety Differences

use axum::{
    Router,
    extract::State,
    Extension,
    routing::get,
    response::IntoResponse,
};
 
// === STATE: Compile-time type safety ===
 
#[derive(Clone)]
struct MyState {
    value: String,
}
 
async fn state_handler(State(state): State<MyState>) -> impl IntoResponse {
    // Compiler knows state is MyState
    state.value.clone()
}
 
// This would NOT compile - wrong state type:
// async fn wrong_state(State(state): State<String>) -> impl IntoResponse {
//     state
// }
 
// === EXTENSION: Runtime type checking ===
 
#[derive(Clone)]
struct MyExtension {
    value: String,
}
 
async fn extension_handler(Extension(ext): Extension<MyExtension>) -> impl IntoResponse {
    ext.value.clone()
}
 
// This WOULD compile but PANIC at runtime - wrong type:
// async fn wrong_extension(Extension(ext): Extension<String>) -> impl IntoResponse {
//     ext
// }
// Panics with: "Extension extraction failed: no extension of type String found"

State enforces types at compile time; Extension panics at runtime on type mismatch.

How Extension Works Internally

use axum::Extension;
use std::any::Any;
use std::sync::Arc;
 
// Simplified view of Extension internals
mod extension_internals {
    use std::any::{Any, TypeId};
    use std::collections::HashMap;
    use std::sync::Arc;
    
    // The actual Extensions type is a type-erased map
    pub struct Extensions {
        map: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
    }
    
    impl Extensions {
        pub fn insert<T: Clone + Send + Sync + 'static>(&mut self, value: T) {
            self.map.insert(TypeId::of::<T>(), Box::new(value));
        }
        
        pub fn get<T: Clone + Send + Sync + 'static>(&self) -> Option<&T> {
            self.map
                .get(&TypeId::of::<T>())
                .and_then(|v| v.downcast_ref::<T>())
        }
    }
}
 
// Extension<T> extracts T from this map at runtime
// This requires:
// 1. TypeId lookup in HashMap
// 2. downcast_ref to convert Any to concrete type
// 3. Clone to return owned value

Extension uses type erasure with Box<dyn Any> and runtime downcasting.

How State Works Internally

use axum::extract::State;
 
// Simplified view of State mechanism
mod state_internals {
    // The router stores the state directly with its type
    // Handlers receive &S or S depending on Clone
    
    // State extractor implementation (simplified)
    pub struct State<S>(pub S);
    
    // The extractor receives the state from the request extensions
    // but the TYPE is known at compile time from the router
    // 
    // Router::with_state::<S>(state) stores S in request
    // State<S> extractor retrieves it with the known type
    // 
    // Key: The state type S is part of the Router's type:
    // Router<(), S> where S is the state type
}

State maintains full type information throughout the request pipeline.

Performance Comparison

use axum::{extract::State, Extension, response::IntoResponse};
use std::sync::Arc;
use std::time::Instant;
 
#[derive(Clone)]
struct Data {
    value: Arc<String>,
}
 
// State: Direct reference, no allocation
async fn state_handler(State(data): State<Data>) -> impl IntoResponse {
    // Single pointer dereference
    data.value.as_str()
}
 
// Extension: Type-erased lookup + downcast + clone
async fn extension_handler(Extension(data): Extension<Data>) -> impl IntoResponse {
    // 1. HashMap lookup by TypeId
    // 2. downcast_ref from Any
    // 3. Clone (required by Extension)
    data.value.as_str()
}
 
// Performance difference:
// - State: O(1), no allocation, no runtime checks
// - Extension: O(1) HashMap lookup + downcast + clone
// 
// The difference is small per-request but measurable under load:
// - State has lower latency
// - State has better cache locality
// - Extension allocates on each extraction (clone)

State is more efficient: no type erasure, no downcasting, no mandatory cloning.

Multiple State Types vs Extensions

use axum::{
    Router,
    extract::State,
    Extension,
    routing::get,
    response::IntoResponse,
};
use std::sync::Arc;
 
#[derive(Clone)]
struct Database {
    url: String,
}
 
#[derive(Clone)]
struct Cache {
    addr: String,
}
 
#[derive(Clone)]
struct Config {
    debug: bool,
}
 
// === Using State with combined struct ===
 
#[derive(Clone)]
struct AppState {
    db: Database,
    cache: Cache,
    config: Config,
}
 
async fn state_db(State(state): State<AppState>) -> impl IntoResponse {
    state.db.url.clone()
}
 
// === Using Extension for multiple types ===
 
async fn extension_db(Extension(db): Extension<Database>) -> impl IntoResponse {
    db.url.clone()
}
 
async fn extension_cache(Extension(cache): Extension<Cache>) -> impl IntoResponse {
    cache.addr.clone()
}
 
async fn extension_config(Extension(config): Extension<Config>) -> impl IntoResponse {
    format!("Debug: {}", config.debug)
}
 
#[tokio::main]
async fn main() {
    // State approach: single struct
    let state = AppState {
        db: Database { url: "postgres://...".to_string() },
        cache: Cache { addr: "localhost:6379".to_string() },
        config: Config { debug: true },
    };
    
    let state_app = Router::new()
        .route("/db", get(state_db))
        .with_state(state);
    
    // Extension approach: multiple extensions
    let ext_app = Router::new()
        .route("/db", get(extension_db))
        .route("/cache", get(extension_cache))
        .route("/config", get(extension_config))
        .layer(Extension(Database { url: "postgres://...".to_string() }))
        .layer(Extension(Cache { addr: "localhost:6379".to_string() }))
        .layer(Extension(Config { debug: true }));
}

State uses a single struct; Extension allows multiple independent types.

Middleware and Extension

use axum::{
    Router,
    Extension,
    routing::get,
    response::IntoResponse,
    middleware::{self, Next},
    http::Request,
};
use std::sync::Arc;
 
// Extension is ideal for middleware-added data
#[derive(Clone)]
struct RequestId(String);
 
async fn add_request_id<B>(
    Extension(request_id): Extension<RequestId>,
    mut request: Request<B>,
    next: Next<B>,
) -> impl IntoResponse {
    // Add request ID to request extensions for handlers
    let id = uuid::Uuid::new_v4().to_string();
    request.extensions_mut().insert(RequestId(id.clone()));
    
    next.run(request).await
}
 
async fn handler(Extension(request_id): Extension<RequestId>) -> impl IntoResponse {
    format!("Request ID: {}", request_id.0)
}
 
// This is where Extension shines: cross-cutting concerns
// added by middleware that handlers didn't anticipate

Extension is the natural choice for middleware-injected data.

Sub-routers and State

use axum::{
    Router,
    extract::State,
    routing::get,
    response::IntoResponse,
};
 
#[derive(Clone)]
struct AppState {
    value: String,
}
 
async fn root_handler(State(state): State<AppState>) -> impl IntoResponse {
    state.value.clone()
}
 
async fn api_handler(State(state): State<AppState>) -> impl IntoResponse {
    state.value.clone()
}
 
#[tokio::main]
async fn main() {
    let state = AppState {
        value: "shared".to_string(),
    };
    
    // State flows to sub-routers automatically
    let api_routes = Router::new()
        .route("/users", get(api_handler));
    
    let app = Router::new()
        .route("/", get(root_handler))
        .nest("/api", api_routes)
        .with_state(state);
    
    // Both root and api handlers receive the same AppState
}

State naturally propagates to nested routers with the same type.

Sub-routers and Extensions

use axum::{
    Router,
    Extension,
    routing::get,
    response::IntoResponse,
};
 
#[derive(Clone)]
struct SharedData {
    value: String,
}
 
async fn handler(Extension(data): Extension<SharedData>) -> impl IntoResponse {
    data.value.clone()
}
 
#[tokio::main]
async fn main() {
    let shared = SharedData {
        value: "shared".to_string(),
    };
    
    // Extensions need to be added at the right layer level
    let api_routes = Router::new()
        .route("/users", get(handler));
    
    let app = Router::new()
        .route("/", get(handler))
        .nest("/api", api_routes)
        .layer(Extension(shared));
    
    // Extension added here applies to ALL routes
    // Layer ordering matters!
}

Extension layers must be positioned carefully for proper scope.

When to Use State

use axum::{
    Router,
    extract::State,
    routing::{get, post},
    response::IntoResponse,
    Json,
};
use std::sync::Arc;
use tokio::sync::RwLock;
 
// Use State for core application state
 
#[derive(Clone)]
struct AppState {
    // Database connections
    db: DbPool,
    // Configuration
    config: Arc<Config>,
    // Shared mutable state
    cache: Arc<RwLock<Cache>>,
    // Business logic services
    user_service: UserService,
}
 
struct DbPool;
struct Config;
struct Cache;
struct UserService;
 
// Handlers receive the full application state
async fn get_user(
    State(state): State<AppState>,
) -> impl IntoResponse {
    // All application context available
    let db = &state.db;
    let config = &state.config;
    // ...
    "user data"
}
 
async fn create_user(
    State(state): State<AppState>,
    Json(payload): Json<CreateUser>,
) -> impl IntoResponse {
    // Same state, same type safety
    "created"
}
 
struct CreateUser;
 
#[tokio::main]
async fn main() {
    let state = AppState {
        db: DbPool,
        config: Arc::new(Config),
        cache: Arc::new(RwLock::new(Cache)),
        user_service: UserService,
    };
    
    let app = Router::new()
        .route("/users", get(get_user))
        .route("/users", post(create_user))
        .with_state(state);
}

Use State for primary application state that handlers depend on.

When to Use Extension

use axum::{
    Router,
    Extension,
    routing::get,
    response::IntoResponse,
    middleware::{self, Next},
    http::Request,
};
use std::sync::Arc;
 
// Use Extension for middleware-added data
 
// Request-scoped data added by middleware
#[derive(Clone)]
struct RequestContext {
    request_id: String,
    user_id: Option<String>,
    start_time: std::time::Instant,
}
 
// Legacy integration where type isn't known at router build time
#[derive(Clone)]
struct LegacyData {
    value: String,
}
 
async fn middleware_example<B>(
    Extension(mut ctx): Extension<RequestContext>,
    req: Request<B>,
    next: Next<B>,
) -> impl IntoResponse {
    // Modify context per-request
    next.run(req).await
}
 
async fn handler(
    Extension(ctx): Extension<RequestContext>,
    Extension(legacy): Extension<LegacyData>,
) -> impl IntoResponse {
    format!("Request: {}, Legacy: {}", ctx.request_id, legacy.value)
}
 
#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/handler", get(handler))
        .layer(Extension(RequestContext {
            request_id: String::new(),
            user_id: None,
            start_time: std::time::Instant::now(),
        }))
        .layer(Extension(LegacyData {
            value: "legacy".to_string(),
        }));
}

Use Extension for middleware-injected data and cross-cutting concerns.

Clone Requirements

use axum::{
    Router,
    extract::State,
    Extension,
    routing::get,
    response::IntoResponse,
};
use std::sync::Arc;
 
#[derive(Clone)]
struct MyData {
    value: Arc<String>,
}
 
// State: Can receive reference or owned
async fn state_ref(State(data): State<MyData>) -> impl IntoResponse {
    // data is MyData (cloned, but Arc clone is cheap)
    data.value.as_str()
}
 
// Can also receive Arc directly for more control
#[derive(Clone)]
struct MyData2 {
    value: Arc<String>,
}
 
async fn state_arc(State(data): State<Arc<MyData2>>) -> impl IntoResponse {
    // data is Arc<MyData2>, single reference
    data.value.as_str()
}
 
// Extension: Always requires Clone
async fn extension_handler(Extension(data): Extension<MyData>) -> impl IntoResponse {
    // Extension extraction ALWAYS clones
    // MyData must be Clone
    data.value.as_str()
}
 
// If clone is expensive, wrap in Arc
#[derive(Clone)]
struct ExpensiveData {
    big_data: Arc<Vec<u8>>,
}
 
async fn expensive_extension(
    Extension(data): Extension<Arc<ExpensiveData>>
) -> impl IntoResponse {
    // Arc clone is cheap
    format!("{} bytes", data.big_data.len())
}

Both require Clone, but State gives more control over the reference type.

Combining State and Extension

use axum::{
    Router,
    extract::State,
    Extension,
    routing::get,
    response::IntoResponse,
    middleware::{self, Next},
    http::Request,
};
use std::sync::Arc;
 
#[derive(Clone)]
struct AppState {
    db: Database,
    config: Config,
}
 
struct Database;
struct Config;
 
// Middleware adds per-request context
#[derive(Clone)]
struct RequestContext {
    request_id: String,
}
 
async fn auth_middleware<B>(
    State(state): State<AppState>,
    Extension(ctx): Extension<RequestContext>,
    req: Request<B>,
    next: Next<B>,
) -> impl IntoResponse {
    // Can use both State and Extension in middleware
    next.run(req).await
}
 
// Handler uses both State and Extension
async fn handler(
    State(state): State<AppState>,
    Extension(ctx): Extension<RequestContext>,
) -> impl IntoResponse {
    format!("Request {} using {:?}", ctx.request_id, state.db)
}
 
#[tokio::main]
async fn main() {
    let state = AppState {
        db: Database,
        config: Config,
    };
    
    let app = Router::new()
        .route("/api", get(handler))
        .layer(Extension(RequestContext {
            request_id: "default".to_string(),
        }))
        .with_state(state);
}

State and Extension can be used together for different purposes.

Migration from Extension to State

use axum::{
    Router,
    extract::State,
    Extension,
    routing::get,
    response::IntoResponse,
};
 
// BEFORE: Using Extension for everything
#[derive(Clone)]
struct Config {
    db_url: String,
}
 
async fn old_handler(Extension(config): Extension<Config>) -> impl IntoResponse {
    config.db_url.clone()
}
 
// AFTER: Migrate to State
 
#[derive(Clone)]
struct AppState {
    config: Config,
}
 
async fn new_handler(State(state): State<AppState>) -> impl IntoResponse {
    state.config.db_url.clone()
}
 
// Gradual migration: support both during transition
async fn transition_handler(
    Extension(config): Extension<Config>,
    State(state): State<AppState>,
) -> impl IntoResponse {
    // Prefer State, fall back to Extension during migration
    state.config.db_url.clone()
}
 
#[tokio::main]
async fn main() {
    let state = AppState {
        config: Config { db_url: "postgres://...".to_string() },
    };
    
    let app = Router::new()
        .route("/new", get(new_handler))
        .route("/transition", get(transition_handler))
        .layer(Extension(state.config.clone()))  // Still support Extension
        .with_state(state);
}

Gradual migration allows moving from Extension to State incrementally.

Summary Comparison

Aspect State Extension
Type safety Compile-time Runtime (panics on mismatch)
Performance Direct reference HashMap lookup + downcast + clone
Ownership Router owns, handlers reference Shared via Clone
Use case Application state Middleware-added data
Multiple values Single struct Multiple Extensions
Router type Router<(), S> Any router
Propagation Automatic to nested routers Requires layer ordering
Error handling Compile error Runtime panic

Synthesis

State and Extension serve different purposes in Axum:

Use State for:

  • Core application state (database pools, config, services)
  • Data known at router build time
  • When type safety matters
  • When performance matters
  • When you want compile-time guarantees

Use Extension for:

  • Middleware-injected data (request IDs, auth info)
  • Cross-cutting concerns
  • Data added dynamically
  • Legacy integration
  • When you need multiple independent types

Key insight: State is the idiomatic choice for application state because it encodes the state type in the router's type signature, enabling compile-time verification that handlers receive the correct type. Extension exists as a more flexible mechanism suitable for middleware-added data where the handler doesn't choose what it receives. The flexibility of Extension comes at a cost: runtime type checking, mandatory cloning, and the possibility of panics. For primary application state, prefer State; reserve Extension for cross-cutting middleware concerns.