How to use SQLx with Axum

Connect Axum to SQLx by injecting a PgPool into handlers using the State extractor.

When handlers need data

You've built an Axum server that routes requests to handlers. Now you need to talk to a database. The naive approach is to create a connection inside every handler. That kills performance and exhausts your database connection limits. The other naive approach is to stash the pool in a static variable and mutate it globally. That breaks the borrow checker, makes testing impossible, and invites data races.

You need a way to share the database connection pool across handlers without fighting the compiler or sacrificing safety. Axum provides State for exactly this. It injects shared data into handlers via dependency injection. SQLx provides PgPool (or MySqlPool, SqlitePool) as the shared resource. Combine them, and your handlers get database access that is safe, concurrent, and easy to test.

The pool and the pass

A connection pool manages a set of open database connections. Handlers borrow a connection from the pool, run a query, and return the connection. The pool handles lifecycle, retries, and limits. You never manage connections manually.

Think of the pool like a shared kitchen in an office building. You don't build a new kitchen for every meal request. You don't let everyone run around with the master keys. You have a central kitchen, and every worker gets a temporary pass to use it when needed. Axum's State extractor is that pass system. It hands the kitchen access to the handler, the handler uses it, and the pass disappears when the request is done. The pool stays alive for the next request.

PgPool implements Clone. Cloning a pool is cheap. It doesn't open a new connection. It just increments a reference count to the underlying pool. You can clone the pool as many times as you want. The cost is negligible. Treat the pool like a reference, not a heavy resource.

Minimal setup

This example creates a pool, attaches it to the router, and extracts it in a handler. Copy this into a fresh project with axum, sqlx, and tokio dependencies.

use axum::{extract::State, routing::get, Router};
use sqlx::postgres::{PgPool, PgPoolOptions};
use std::time::Duration;

/// Main entry point: sets up the database pool and starts the server.
#[tokio::main]
async fn main() {
    // Create a connection pool with sensible defaults.
    // max_connections limits concurrency to avoid overwhelming the database.
    // acquire_timeout prevents handlers from hanging forever if the pool is exhausted.
    let pool = PgPoolOptions::new()
        .max_connections(5)
        .acquire_timeout(Duration::from_secs(3))
        .connect("postgres://postgres:password@localhost")
        .await
        .expect("failed to connect to database");

    // Build the router and attach the pool as shared state.
    // .with_state makes the pool available to all routes via the State extractor.
    let app = Router::new()
        .route("/", get(handler))
        .with_state(pool);

    // Bind to localhost and start serving.
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

/// Handler that queries the database using the injected pool.
async fn handler(State(pool): State<PgPool>) -> String {
    // Execute a simple query against the pool.
    // fetch_one returns a single row; query_scalar extracts the first column.
    let result: String = sqlx::query_scalar("SELECT 'hello from pg'")
        .fetch_one(&pool)
        .await
        .expect("query failed");
    result
}

Copy this skeleton. Your handlers get the pool. The compiler enforces the rest.

Under the hood

When main calls connect, SQLx establishes initial connections to the database. The pool starts with zero or one connections depending on configuration. PgPoolOptions::max_connections sets the hard limit. If you set this too low, requests queue up. If you set it too high, you overwhelm the database. Start with 5 to 10 for small apps. Scale based on load testing.

acquire_timeout is critical. If all connections are busy and a new request arrives, the handler waits for a free connection. Without a timeout, the handler hangs indefinitely. The timeout returns an error after the duration expires. This prevents thread starvation in the Tokio runtime. Set it to 3 to 5 seconds. If your queries take longer, fix the queries or increase the pool size.

When a request hits /, Axum creates a State<PgPool> extractor. This extractor clones the pool from the router's internal storage. The clone is a reference-count bump. The handler receives the cloned pool. The handler runs the query. The query borrows a connection from the pool, executes, and returns the connection. The handler returns the response. The cloned pool goes out of scope. The reference count drops. The pool stays alive because the router still holds the original.

SQLx validates queries at compile time. The query_scalar function checks the SQL string against the database schema. This requires a running database during compilation or an offline dump file. If the schema changes, the build fails. This catches typos and type mismatches before deployment. Enable the runtime-tokio and postgres features in Cargo.toml for this to work.

Real-world structure

Real applications have more than a database. You need configuration, cache clients, API keys, or multiple pools. Injecting raw PgPool works for one dependency. It breaks when you add a second. Every handler signature changes. Refactoring becomes painful.

The standard pattern is a wrapper struct. Define AppState, derive Clone, and put all dependencies inside. Axum requires state to be clonable. PgPool is clonable, so the struct is clonable. This scales cleanly. Add a field to the struct, and handlers access it via state.field. You never change the extractor type.

use axum::{extract::State, routing::{get, post}, Json, Router};
use sqlx::postgres::{PgPool, PgPoolOptions};
use serde::{Deserialize, Serialize};
use std::time::Duration;

/// Shared application state containing the database pool and other dependencies.
/// Wrapping the pool in a struct makes it easy to add more services later
/// without changing every handler signature.
#[derive(Clone)]
struct AppState {
    pool: PgPool,
}

#[tokio::main]
async fn main() {
    let pool = PgPoolOptions::new()
        .max_connections(10)
        .acquire_timeout(Duration::from_secs(5))
        .connect("postgres://postgres:password@localhost")
        .await
        .expect("db connection failed");

    // Wrap the pool in AppState.
    let state = AppState { pool };

    let app = Router::new()
        .route("/users", get(list_users))
        .route("/users", post(create_user))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

#[derive(Serialize)]
struct User {
    id: i64,
    name: String,
}

/// List all users from the database.
async fn list_users(State(state): State<AppState>) -> Json<Vec<User>> {
    // query_as! maps the result row to the User struct.
    // fetch_all returns all matching rows.
    let users = sqlx::query_as!(User, "SELECT id, name FROM users")
        .fetch_all(&state.pool)
        .await
        .expect("fetch users failed");
    Json(users)
}

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

/// Create a new user.
async fn create_user(
    State(state): State<AppState>,
    Json(payload): Json<CreateUserRequest>,
) -> String {
    // query! executes the statement and validates parameters at compile time.
    // $1 binds the first parameter.
    sqlx::query!("INSERT INTO users (name) VALUES ($1)", payload.name)
        .execute(&state.pool)
        .await
        .expect("insert failed");
    format!("Created user: {}", payload.name)
}

Start with a wrapper struct. You'll add a cache or config soon, and you won't want to rewrite every handler signature.

Pitfalls and compiler traps

If you define AppState but forget #[derive(Clone)], the compiler rejects the router setup with E0277 (trait bound not satisfied). Axum requires state to be clonable because it shares the state across concurrent requests. Add the derive macro.

If you mix up the state type in the handler, you get E0308 (mismatched types). The extractor expects exactly what you passed to with_state. If you passed AppState, the handler must extract State<AppState>. Check the generic parameter.

SQLx macros require runtime features. If you see errors about missing methods on PgPool or query! not expanding, check Cargo.toml. You need sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }. The runtime feature must match your async runtime. runtime-tokio for Tokio. runtime-actix for Actix. Missing features break the build silently until you hit database code.

fetch_one panics if the query returns zero rows. Use fetch_optional when a query might legitimately return no rows. fetch_optional returns Option<T>. Handle the None case gracefully. Use fetch_one only when the schema guarantees a row exists, like a primary key lookup.

Handlers should return Result types in production. The examples use .expect() for brevity. Real handlers return Result<T, impl IntoResponse>. Axum converts errors to HTTP responses automatically. Define an error type that implements IntoResponse. Return Err(MyError::NotFound) instead of panicking.

Verify your Cargo.toml features. Missing a runtime feature breaks the build silently until you hit the database code.

Choosing your approach

Use State<PgPool> directly when your application only depends on the database and you have no other shared configuration. Use a wrapper struct like AppState when you need to inject multiple dependencies such as a cache client, API keys, or a second database pool. Use the sqlx::query! macro for static queries to get compile-time validation of SQL syntax and result types. Use the sqlx::query function for dynamic queries where table names or conditions are constructed at runtime. Use fetch_optional when a query might legitimately return no rows. Use fetch_one only when the schema guarantees a row exists, like a primary key lookup.

Match the extractor to your dependency graph. Simple apps get simple state. Complex apps get a struct.

Where to go next