What is the difference between axum::Extension and State for sharing application data across handlers?

axum::Extension stores shared data in a type-erased map using TypeId keys with Arc wrapping, while State passes typed data directly through the request context with zero overhead—making State more performant and type-safe but Extension more flexible for optional data and multiple data types. Both mechanisms allow sharing application state across handlers, but Extension uses middleware layers to insert data into a type map that handlers extract, whereas State is a direct typed parameter passed to handlers with compile-time guarantees. The choice depends on whether you need flexibility (multiple optional state types) or performance (single required state type).

Understanding State Sharing in Axum

use axum::{
    Router,
    Extension,
    extract::State,
    routing::get,
};
use std::sync::Arc;
 
// Both Extension and State allow sharing data with handlers
 
// Extension approach
async fn handler_with_extension(
    Extension(db): Extension<Arc<Database>>,
) -> String {
    format!("Database: {:?}", db)
}
 
// State approach
async fn handler_with_state(
    State(db): State<Arc<Database>>,
) -> String {
    format!("Database: {:?}", db)
}
 
#[derive(Debug)]
struct Database {
    url: String,
}

Both mechanisms inject shared data into handlers, but with different implementation strategies.

Extension: Type-Erased Storage

use axum::{Router, Extension, routing::get};
use std::sync::Arc;
 
async fn get_user(Extension(db): Extension<Arc<Database>>) -> String {
    db.get_user()
}
 
async fn get_post(Extension(db): Extension<Arc<Database>>) -> String {
    db.get_post()
}
 
#[tokio::main]
async fn main() {
    let db = Arc::new(Database::new("postgres://..."));
    
    // Extension adds data to a type map
    let app = Router::new()
        .route("/user", get(get_user))
        .route("/post", get(get_post))
        .layer(Extension(db));  // Added via middleware layer
    
    // Extension uses type erasure internally
    // Data is stored as: HashMap<TypeId, Arc<dyn Any>>
}
 
struct Database {
    url: String,
}
 
impl Database {
    fn new(url: &str) -> Self {
        Database { url: url.to_string() }
    }
    
    fn get_user(&self) -> String {
        "user data".to_string()
    }
    
    fn get_post(&self) -> String {
        "post data".to_string()
    }
}

Extension stores data in a type-erased map, extracting by type at runtime.

State: Direct Typed State

use axum::{Router, extract::State, routing::get};
use std::sync::Arc;
 
async fn get_user(State(db): State<Arc<Database>>) -> String {
    db.get_user()
}
 
async fn get_post(State(db): State<Arc<Database>>) -> String {
    db.get_post()
}
 
#[tokio::main]
async fn main() {
    let db = Arc::new(Database::new("postgres://..."));
    
    // State is passed directly via with_state
    let app = Router::new()
        .route("/user", get(get_user))
        .route("/post", get(get_post))
        .with_state(db);  // Typed state passed directly
}
 
// State has no type erasure - direct reference to typed data

State passes typed data directly to handlers without type erasure.

Performance Differences

use axum::{Extension, extract::State};
use std::sync::Arc;
 
// Extension overhead:
// 1. HashMap lookup by TypeId
// 2. Arc<dyn Any> downcast
// 3. Reference counting overhead
 
async fn extension_handler(Extension(db): Extension<Arc<Database>>) {
    // Runtime lookup: request.extensions().get::<Arc<Database>>()
    // Type cast: Arc<dyn Any> -> Arc<Database>
    // Multiple pointer indirections
}
 
// State overhead:
// 1. Direct reference to state
// 2. No runtime lookup
// 3. No type erasure
 
async fn state_handler(State(db): State<Arc<Database>>) {
    // Direct access: request.state::<Arc<Database>>()
    // Or stored directly in router context
    // Single pointer dereference
}
 
// State is essentially zero-cost abstraction
// Extension has measurable overhead per request

State avoids runtime type lookups and downcasting; Extension pays these costs on every request.

Multiple State Types with Extension

use axum::{Router, Extension, routing::get};
use std::sync::Arc;
 
// Extension supports multiple data types
async fn handler(
    Extension(db): Extension<Arc<Database>>,
    Extension(cache): Extension<Arc<Cache>>,
    Extension(config): Extension<Arc<Config>>,
) -> String {
    format!("db: {}, cache: {}, config: {}", 
        db.url, cache.name, config.debug)
}
 
#[tokio::main]
async fn main() {
    let db = Arc::new(Database::new("postgres://..."));
    let cache = Arc::new(Cache::new("redis://..."));
    let config = Arc::new(Config::default());
    
    // Multiple extensions - each type is stored separately
    let app = Router::new()
        .route("/", get(handler))
        .layer(Extension(db))
        .layer(Extension(cache))
        .layer(Extension(config));
}
 
// Extension can have any number of different types
// Each type is keyed by TypeId in the internal map

Extension naturally supports multiple state types; each type is stored independently.

Single State Type with State

use axum::{Router, extract::State, routing::get};
use std::sync::Arc;
 
// State supports only ONE type per application
// But that type can contain multiple fields
 
struct AppState {
    db: Arc<Database>,
    cache: Arc<Cache>,
    config: Arc<Config>,
}
 
async fn handler(
    State(state): State<Arc<AppState>>,
) -> String {
    format!("db: {}, cache: {}, config: {}",
        state.db.url, state.cache.name, state.config.debug)
}
 
#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        db: Arc::new(Database::new("postgres://...")),
        cache: Arc::new(Cache::new("redis://...")),
        config: Arc::new(Config::default()),
    });
    
    // Only one state type
    let app = Router::new()
        .route("/", get(handler))
        .with_state(state);
}
 
// State approach: bundle multiple resources in one struct

State requires bundling multiple resources into a single struct type.

Compile-Time vs Runtime Errors

use axum::{Extension, extract::State, routing::get, Router};
use std::sync::Arc;
 
// Extension: Runtime errors if missing
async fn extension_handler(Extension(db): Extension<Arc<Database>>) -> String {
    db.url.clone()
}
 
// If Extension is not added, handler panics at runtime:
// "Extension has not been set"
 
// State: Compile-time guarantee
async fn state_handler(State(db): State<Arc<Database>>) -> String {
    db.url.clone()
}
 
// If State is not provided, router won't compile:
// "Router::into_make_service requires that the router has state"
 
// Example of compile-time error:
// let app = Router::new()
//     .route("/", get(state_handler))
//     // .with_state(db)  // MISSING - won't compile!
// ;
// Error: the trait bound `Router<(), ()>: IntoMakeService<...>` is not satisfied
 
fn main() {
    // Extension errors are runtime panics
    // State errors are compile-time
    
    // Extension: handler will panic if Extension not added
    // State: won't compile if with_state not called
}

State provides compile-time guarantees; Extension fails at runtime with panics.

Extracting State in Handlers

use axum::{Extension, extract::State, Json, routing::post};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
 
struct AppState {
    db: Arc<Database>,
}
 
#[derive(Deserialize)]
struct CreateUser {
    name: String,
}
 
#[derive(Serialize)]
struct User {
    id: u64,
    name: String,
}
 
// Extension extraction
async fn create_user_extension(
    Extension(db): Extension<Arc<Database>>,
    Json(payload): Json<CreateUser>,
) -> Json<User> {
    let user = db.create_user(&payload.name);
    Json(user)
}
 
// State extraction
async fn create_user_state(
    State(db): State<Arc<Database>>,
    Json(payload): Json<CreateUser>,
) -> Json<User> {
    let user = db.create_user(&payload.name);
    Json(user)
}
 
// State with bundled resources
async fn create_user_bundled(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<CreateUser>,
) -> Json<User> {
    let user = state.db.create_user(&payload.name);
    Json(user)
}

Both use extractor syntax; State gives direct typed access.

Layer Ordering with Extension

use axum::{Router, Extension, routing::get};
use tower::ServiceBuilder;
use std::sync::Arc;
 
// Extension is added via layers, which have ordering implications
 
async fn handler(
    Extension(db): Extension<Arc<Database>>,
    Extension(config): Extension<Arc<Config>>,
) -> String {
    format!("db: {}, config: {}", db.url, config.debug)
}
 
#[tokio::main]
async fn main() {
    let db = Arc::new(Database::new("postgres://..."));
    let config = Arc::new(Config::default());
    
    // Layer order matters! Extensions must be added before handlers
    let app = Router::new()
        .route("/", get(handler))
        .layer(Extension(db))      // Added first
        .layer(Extension(config));  // Added second
    
    // Layers wrap the service in reverse order:
    // config -> db -> handler
    
    // Alternative: use ServiceBuilder for clarity
    let app = Router::new()
        .route("/", get(handler))
        .layer(
            ServiceBuilder::new()
                .layer(Extension(db))
                .layer(Extension(config))
        );
}

Extension uses layer ordering; careful attention to middleware order is required.

Optional State with Extension

use axum::{Extension, Json, routing::get, Router};
use std::sync::Arc;
 
// Extension can be optional
async fn optional_extension_handler(
    Extension(db): Extension<Option<Arc<Database>>>,
) -> String {
    match db {
        Some(db) => format!("Database: {}", db.url),
        None => "No database configured".to_string(),
    }
}
 
// Or using Option in the layer
async fn handler_with_optional(
    // Handler expects Extension, but we can add Option<T>
) -> String {
    "result".to_string()
}
 
#[tokio::main]
async fn main() {
    // Extension can be omitted entirely
    let app1 = Router::new()
        .route("/", get(optional_extension_handler))
        // No Extension added - but handler expects it!
        // This would panic at runtime
        ;
    
    // Better: add explicit Option
    let app2 = Router::new()
        .route("/", get(|Extension(opt): Extension<Option<Arc<Database>>>| {
            match opt {
                Some(db) => format!("db: {}", db.url),
                None => "no db".to_string(),
            }
        }))
        .layer(Extension(None::<Arc<Database>>));
}

Extension supports optional state; State is always required.

Nesting and Routers

use axum::{Router, Extension, extract::State, routing::get};
use std::sync::Arc;
 
struct AppState {
    db: Arc<Database>,
}
 
// Extension propagates through nested routers automatically
fn create_nested_router_extension() -> Router {
    let db = Arc::new(Database::new("postgres://..."));
    
    let nested = Router::new()
        .route("/inner", get(|Extension(db): Extension<Arc<Database>>| {
            format!("nested db: {}", db.url)
        }));
    
    Router::new()
        .nest("/api", nested)
        .layer(Extension(db))
}
 
// State requires explicit passing to nested routers
fn create_nested_router_state() -> Router {
    let db = Arc::new(Database::new("postgres://..."));
    let state = Arc::new(AppState { db: db.clone() });
    
    let nested = Router::new()
        .route("/inner", get(|State(db): State<Arc<Database>>| {
            format!("nested db: {}", db.url)
        }))
        .with_state(db);  // Must pass state to nested router
    
    Router::new()
        .nest("/api", nested)
        .with_state(state)
}

Extension automatically propagates through nesting; State requires explicit state passing.

Mutable State Patterns

use axum::{Extension, extract::State, routing::post, Json};
use std::sync::Arc;
use tokio::sync::RwLock;
 
// Both Extension and State typically use Arc for shared ownership
// For mutable state, wrap in RwLock or Mutex
 
struct AppState {
    db: Arc<Database>,
    counter: Arc<RwLock<u64>>,
}
 
// Extension with mutable state
async fn increment_extension(
    Extension(counter): Extension<Arc<RwLock<u64>>>,
) -> String {
    let mut count = counter.write().await;
    *count += 1;
    format!("Count: {}", *count)
}
 
// State with mutable state
async fn increment_state(
    State(counter): State<Arc<RwLock<u64>>>,
) -> String {
    let mut count = counter.write().await;
    *count += 1;
    format!("Count: {}", *count)
}
 
// Bundled mutable state
async fn increment_bundled(
    State(state): State<Arc<AppState>>,
) -> String {
    let mut count = state.counter.write().await;
    *count += 1;
    format!("Count: {}", *count)
}

Both approaches use Arc<RwLock<T>> for mutable state; State bundles multiple resources.

Middleware Access to State

use axum::{Extension, extract::State, middleware, routing::get, Router, Request, Next, response::Response};
use std::sync::Arc;
 
struct AppState {
    db: Arc<Database>,
}
 
// Extension can be accessed in middleware
async fn extension_middleware(
    Extension(db): Extension<Arc<Database>>,
    request: Request,
    next: Next,
) -> Response {
    // Can use db in middleware
    println!("Database URL: {}", db.url);
    next.run(request).await
}
 
// State is harder to access in middleware
async fn state_middleware(
    // State is not directly accessible in middleware
    // Must use request extensions
    request: Request,
    next: Next,
) -> Response {
    // Cannot easily access State here
    // Would need to use Extension for middleware access
    next.run(request).await
}
 
#[tokio::main]
async fn main() {
    let db = Arc::new(Database::new("postgres://..."));
    
    // Extension in middleware
    let app_extension = Router::new()
        .route("/", get(|Extension(db): Extension<Arc<Database>>| {
            format!("db: {}", db.url)
        }))
        .layer(middleware::from_fn(extension_middleware))
        .layer(Extension(db));
}

Extension is easier to access in middleware; State requires workarounds.

When to Use Each Approach

use axum::{Extension, extract::State, Router};
use std::sync::Arc;
 
// Use Extension when:
// 1. Multiple independent state types needed
// 2. Optional state (some routes don't need all state)
// 3. Adding state to existing router without changing types
// 4. Middleware needs to access state
// 5. Gradual migration from older axum versions
 
// Use State when:
// 1. Single, required application state
// 2. Performance is critical
// 3. Type safety is important
// 4. New project with clean state structure
// 5. Compile-time guarantees desired
 
fn choose_approach() {
    // Extension: Flexible, runtime-checked
    let app_extension = Router::new()
        .layer(Extension(Arc::new(Database::new("..."))))
        .layer(Extension(Arc::new(Cache::new("..."))))
        .layer(Extension(Arc::new(Config::default())));
    
    // State: Performant, compile-time-checked
    struct AppState {
        db: Arc<Database>,
        cache: Arc<Cache>,
        config: Arc<Config>,
    }
    
    let state = Arc::new(AppState {
        db: Arc::new(Database::new("...")),
        cache: Arc::new(Cache::new("...")),
        config: Arc::new(Config::default()),
    });
    
    let app_state = Router::new()
        .with_state(state);
}

Choose based on flexibility needs vs performance/type-safety requirements.

Migration from Extension to State

use axum::{Extension, extract::State, Router, routing::get};
use std::sync::Arc;
 
// Gradual migration from Extension to State
 
// Step 1: Create a combined state struct
struct AppState {
    db: Arc<Database>,
    cache: Arc<Cache>,
    config: Arc<Config>,
}
 
// Step 2: Update handlers to use State
async fn handler_v2(State(state): State<Arc<AppState>>) -> String {
    format!("db: {}", state.db.url)
}
 
// Step 3: Keep Extension handlers during migration
async fn handler_v1(
    Extension(db): Extension<Arc<Database>>,
    Extension(cache): Extension<Arc<Cache>>,
) -> String {
    format!("db: {}", db.url)
}
 
// Step 4: Use both during transition
#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        db: Arc::new(Database::new("...")),
        cache: Arc::new(Cache::new("...")),
        config: Arc::new(Config::default()),
    });
    
    let app = Router::new()
        .route("/v1", get(handler_v1))
        .route("/v2", get(handler_v2))
        .layer(Extension(state.db.clone()))  // Still needed for v1
        .layer(Extension(state.cache.clone()))
        .with_state(state);  // Added for v2
}

Migration is possible by supporting both approaches during transition.

Synthesis

Architecture comparison:

// Extension: Type-erased map storage
// Internal: HashMap<TypeId, Arc<dyn Any>>
 
let app = Router::new()
    .layer(Extension(db))      // Inserts into type map
    .layer(Extension(cache));  // Another entry in type map
 
// Handler extracts via Extension<T>
async fn handler(Extension(db): Extension<Arc<Database>>) {
    // Runtime: lookup by TypeId, downcast Arc<dyn Any> -> Arc<Database>
}
 
// State: Direct typed storage
// Internal: stored in router context
 
let app = Router::new()
    .with_state(state);  // Single typed state
 
// Handler extracts via State<T>
async fn handler(State(state): State<Arc<AppState>>) {
    // Direct access - no lookup, no downcast
}

Key differences:

// Extension:
// + Multiple state types
// + Optional state support
// + Easy middleware access
// + Good for existing codebases
// - Runtime type lookup
// - Arc overhead per type
// - Panic if missing
// - Type erasure
 
// State:
// + Zero-cost abstraction
// + Compile-time type safety
// + Single reference to state
// + Required by router
// - Single state type
// - Bundle pattern needed for multiple resources
// - Harder middleware access
// - Requires struct for multiple resources

Choosing between them:

// Use Extension for:
// - Multiple independent state types
// - Optional or conditional state
// - Middleware that needs state
// - Quick prototypes
// - Migration from older axum
 
// Use State for:
// - New production applications
// - Single, well-defined state
// - Performance-critical handlers
// - Type safety requirements
// - Compile-time guarantees

Key insight: Extension and State serve similar purposes—sharing application data across handlers—but differ fundamentally in implementation and trade-offs. Extension stores data in a type-erased map using TypeId keys, allowing multiple independent state types to coexist and supporting optional state, but requiring runtime lookups and Arc overhead. State passes a single typed state directly through the request context, providing zero-cost access and compile-time guarantees, but requiring all state to be bundled into a single struct type. For most production applications, State is preferred for its type safety and performance; Extension is valuable for optional state, middleware access, and gradual migrations. The choice ultimately depends on whether you prioritize flexibility (Extension) or performance and type safety (State).