When one connection isn't enough
You're building a web service in Rust. You write a handler that fetches a user from the database. You run the server, hit the endpoint, and the request hangs. Every subsequent request waits for that one database call to finish. Or you try to share a database connection between handlers, and the compiler rejects you with errors about Send and Sync bounds. You need a way to talk to the database without blocking your threads, and you need a way to share that connection safely across the whole application.
sqlx solves both problems. It provides async database access with a connection pool that is thread-safe by default. The pool manages a set of open connections to the database. When your app needs to run a query, it checks out a connection, runs the query, and checks it back in. If all connections are busy, the request waits until one becomes available. This prevents your app from overwhelming the database with thousands of connections and keeps latency predictable.
The pool handles the waiting
Think of a connection pool like a fleet of rental cars. You don't buy a new car for every trip. You grab one from the lot, drive it, and return it. If everyone is using a car, you wait at the counter until one comes back. The pool tracks how many cars are out, how many are in the lot, and how long you've been waiting.
sqlx implements this pattern for database connections. The pool lives on the heap and is wrapped in an atomic reference count. You can clone the pool handle and pass it to any thread. The clone is cheap. It just copies a pointer to the shared pool state. The pool handles the concurrency, the connection lifecycle, and the error recovery.
use sqlx::postgres::PgPool;
#[tokio::main]
async fn main() {
// PgPoolOptions configures the pool behavior.
// max_connections limits how many simultaneous connections the pool opens.
// acquire_timeout sets how long a request waits for a connection before failing.
let pool = PgPool::connect_with(
"postgres://user:password@localhost/mydb"
)
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(5))
.connect()
.await
.expect("Failed to connect to database");
// query_scalar builds a query that returns a single value.
// fetch_one executes the query and expects exactly one row.
let version: String = sqlx::query_scalar("SELECT version()")
.fetch_one(&pool)
.await
.expect("Query failed");
println!("PostgreSQL version: {}", version);
}
What happens under the hood
When you call PgPool::connect, sqlx creates the pool structure and opens connections up to the configured minimum. The pool tracks available connections in a channel. When you call fetch_one(&pool), the method borrows the pool and requests a connection from the channel. If a connection is available, it checks it out immediately. If not, it waits up to the acquire_timeout. Once the query runs, the connection returns to the channel.
The pool also handles connection health. If a connection fails during a query, the pool marks it as broken and removes it. The next request gets a fresh connection. This keeps the pool resilient to network glitches.
Convention note: PgPool implements Clone cheaply. The community convention is to clone the pool directly. Do not wrap PgPool in Arc. PgPool is already an Arc under the hood. Wrapping it in Arc adds noise without benefit. Just call pool.clone() and pass the handle wherever you need it.
Wiring it into a web server
In a real application, you share the pool across all your routes. Frameworks like Axum provide a State extractor that pulls the pool from the app context. The extractor clones the pool handle for each request, which is efficient.
use axum::{Router, extract::State, routing::get};
use sqlx::postgres::PgPool;
use std::net::SocketAddr;
#[tokio::main]
async fn main() {
let pool = PgPool::connect("postgres://user:password@localhost/mydb")
.await
.expect("Failed to connect");
// with_state attaches the pool to the router.
// All routes can now extract the pool via State<PgPool>.
let app = Router::new()
.route("/health", get(health_check))
.with_state(pool);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
axum::serve(
tokio::net::TcpListener::bind(addr).await.unwrap(),
app
)
.await
.unwrap();
}
// State<PgPool> extracts a clone of the pool from the app state.
async fn health_check(State(pool): State<PgPool>) -> String {
let status: String = sqlx::query_scalar("SELECT 'ok'")
.fetch_one(&pool)
.await
.expect("Health check failed");
status
}
The with_state method stores the pool in the router. When a request hits /health, Axum extracts the pool and passes it to the handler. The handler borrows the pool to run the query. The pool manages the connection checkout and return. The handler returns the result, and the request completes.
Pitfalls and compiler signals
If your struct does not match the database columns, the compiler rejects you with E0277 (the trait FromRow is not implemented). This happens when column names do not match field names, or when types do not align. Fix it by renaming fields with #[sqlx(rename = "column_name")] or adjusting the struct to match the query output.
use sqlx::FromRow;
#[derive(FromRow)]
struct User {
// The database column is "user_name", but the Rust field is "username".
// sqlx maps them via the rename attribute.
#[sqlx(rename = "user_name")]
username: String,
email: String,
}
If you move the pool into a closure without cloning, you get E0382 (use of moved value). This happens when you pass the pool by value instead of by reference or clone. The pool is Clone, so clone it before moving it into async tasks.
Never call .block_on() inside an async function. This blocks the runtime thread and causes deadlocks. sqlx queries are async. Await them. If you need to run blocking code, use tokio::task::spawn_blocking.
Pool exhaustion is a runtime risk. If your queries take too long or you hold connections for extended periods, the pool runs out of connections. Requests start failing with timeout errors. Set acquire_timeout to fail fast rather than hanging indefinitely. Monitor pool metrics in production to tune max_connections.
Check your FromRow implementations early. A mismatched column name is a runtime crash waiting to happen. Use compile-time checked queries to catch these errors during development.
Choosing the right query style
sqlx provides multiple query builders. Pick the one that matches your data shape and performance needs.
Use query_scalar when you expect a single value back, like a count or an ID. It avoids the overhead of mapping rows to structs and returns the raw value directly.
Use query_as when you want to map rows directly to a Rust struct. It requires the struct to implement FromRow, which sqlx can derive. This is the standard pattern for fetching entities.
Use query when you need full control over row iteration or are processing a large result set where mapping to structs would consume too much memory. You can stream rows one by one and process them incrementally.
Use query_with when you need to customize the query execution, such as setting query names for profiling or overriding type mappings. This is rare but useful for advanced scenarios.
Reach for sqlx::migrate! when you need to manage schema changes. Running raw SQL strings in code is fragile. Migrations keep your schema versioned and reproducible across environments.
Pick the query builder that matches your data shape. Less mapping means less friction.