The first thing that clicks when moving from a TypeScript backend to Rust: compile-time SQL validation
If you've built a backend in TypeScript, you've probably run into this at least once. A 500 error fires in production, you open the logs and see column "user_name" does not exist — turns out you had written the column name as userName. Tests were in place, staging was passed, yet it slipped through. ORMs catch some of this with types, but dynamically generated queries offer no way to know until runtime.
When I first looked at sqlx's compile-time query validation while considering a Rust migration, I honestly thought it was marketing hype. But then I ran a migration script, made a common typo like SELECT crated_at FROM posts, and watched cargo build fail right there on the spot — "oh, this isn't caught in CI, it's caught in my editor." Even when I tried binding an i32 to a TEXT column, the build failed with a type mismatch.
This post walks through how to put a connection pool on the type system and catch SQL errors at build time using the latest 2026 combination of Axum 0.8 and sqlx 0.8, written from the perspective of a TypeScript backend developer. The focus is on "this is how you make it work" over theory.
Why This Combination
The Pain Points in TypeScript
The TypeScript + Prisma/TypeORM combination is genuinely productive. Type inference works well and migrations are convenient. But when traffic increases or queries get complex, you keep hitting the same friction points.
- Having to trust the ORM-generated queries without being able to see them
- Complex queries like JOINs or window functions force you out into raw queries, where type safety vanishes
- Column typos and type mismatches that only surface at runtime
Rust + sqlx approaches these pain points differently. You write pure SQL without an ORM DSL, and macros verify your queries against a real DB at compile time.
What Axum 0.8 Changed
Axum 0.8 was released in January 2025, and the biggest change is the removal of the #[async_trait] macro dependency. In 0.7 and below, you had to attach this macro when writing custom Extractors, which made type error messages complex and IDE support awkward. From 0.8 onward, it is rewritten using Rust-native impl Future, so you use standard syntax throughout.
Path parameter syntax has also changed. /:id is now /{id}, and /*path is now /{*path}, so if you're upgrading from 0.7, all route definitions need to be updated.
What Changed in sqlx 0.8
sqlx 0.8 isn't just a version bump — there are several changes from 0.7 that affect real-world usage.
- Driver crates are now separate:
sqlx-postgres,sqlx-mysql, andsqlx-sqlite. They can still be activated via feature flags on thesqlxcrate as before, but the internal structure is cleaner. - The
AnyPoolAPI has been redesigned, making code that works with multiple databases simultaneously more explicit. - The type mappings recognized by macros have expanded (e.g., improved PostgreSQL array and custom enum support).
- The offline cache format changed from a single
sqlx-data.jsonfile to a.sqlx/directory (introduced in late 0.7, stabilized in 0.8). This affects CI workflows.
All code in this post targets 0.8.
Setup: Schema and Project
Schema Used in Examples
All examples in this post assume the schema below. To follow along, apply it to your local DB first.
-- migrations/20260101000000_init.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
balance BIGINT NOT NULL DEFAULT 0
);Paste it into the file created by sqlx migrate add init and apply with sqlx migrate run, or run it directly via psql.
Cargo.toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = [
"runtime-tokio",
"tls-rustls", # required when production DB requires TLS
"postgres",
"macros",
"migrate",
"uuid",
"chrono",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["trace", "cors"] }
dotenvy = "0.15"
thiserror = "2"
tracing = "0.1"Two features are especially important. Omitting macros means you can't use the query! and query_as! macros. And omitting tls-rustls (or tls-native-tls) limits you to plain-text local connections — connections to real databases that require TLS (which most managed PostgreSQL services do) will be refused at runtime. Note that runtime-tokio alone does not enable TLS.
Environment Configuration
# .env
DATABASE_URL=postgres://user:password@localhost:5432/myappdotenvy loads this file. sqlx macros read this value at compile time to connect to the actual DB.
Putting the Connection Pool on the Type System
In TypeScript, you typically export a singleton DB client at the module level, or attach it to req.app.locals in Express. Axum handles this through the State<T> extractor, and "managing with types" here offers several concrete guarantees beyond simply storing and retrieving state.
PgPoolisClone+ internallyArc: Even when received by value in each handler, connections are not copied — only the reference count increments. This reduces the risk of accidentally creating multiple pools.PoolConnection<Postgres>RAII: Checking out a connection from the pool wraps it in this type, and it is automatically returned to the pool when it drops out of scope. If you've ever forgottenfinally { client.release() }in TypeScript and caused a connection leak, this alone is a significant safety net.Transaction<'_, Postgres>lifetime: Ifcommit()orrollback()is not called before the transaction handle drops, it is automatically rolled back at drop time. The type system prevents the mistake of "leaving a function without committing."
In other words, "managing the connection pool with types" is not just about dependency injection — it means resource release and transaction completion are enforced by ownership rules.
Simple Case: Injecting Pool Directly as State
use axum::{extract::{Path, State}, routing::get, Router};
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::time::Duration;
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let pool = PgPoolOptions::new()
.max_connections(20)
.acquire_timeout(Duration::from_secs(3))
.connect(&std::env::var("DATABASE_URL").unwrap())
.await
.unwrap();
let app = Router::new()
.route("/users/{id}", get(get_user))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Production Level: AppState Struct
When configuration values or other shared resources are added, group them into a struct.
use std::sync::Arc;
use axum::{extract::{Path, State}, response::IntoResponse, routing::get, Router};
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::time::Duration;
#[derive(Clone)]
struct Config {
jwt_secret: String,
base_url: String,
}
#[derive(Clone)]
struct AppState {
db: PgPool,
config: Arc<Config>,
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let pool = PgPoolOptions::new()
.max_connections(20)
.acquire_timeout(Duration::from_secs(3))
.connect(&std::env::var("DATABASE_URL").unwrap())
.await
.unwrap();
let config = Arc::new(Config {
jwt_secret: std::env::var("JWT_SECRET").unwrap(),
base_url: std::env::var("BASE_URL").unwrap_or_default(),
});
let state = AppState { db: pool, config };
let app = Router::new()
.route("/users/{id}", get(get_user))
.route("/users", get(list_users).post(create_user))
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}In handlers, you can destructure it directly.
async fn get_user(
State(AppState { db, config }): State<AppState>,
Path(id): Path<i64>,
) -> impl IntoResponse {
// use db and config directly
}Multiple Extractors Running in Parallel per Request
The extractors attached to an Axum handler signature are not "pick one of many" — for a single request they each run independently and are passed as arguments.
The reason this parallelism means more than "execute in function argument order" is that if any extractor fails, that failure becomes the response itself and the handler is never called. Path parsing failure maps automatically to 400, JSON deserialization failure to 422 — no need to write defensive code inside the handler.
Compile-Time SQL Validation in Practice
Basics: The query_as! Macro
use axum::{extract::{Path, State}, Json};
#[derive(serde::Serialize, sqlx::FromRow)]
struct User {
id: i64,
name: String,
email: String,
}
async fn get_user(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
id
)
.fetch_one(&state.db)
.await?;
Ok(Json(user))
}sqlx::FromRow is a derive macro that maps query result columns to struct fields, and serde::Serialize is required for Axum's Json<T> responses.
If you introduce a typo like SELECT id, nmae, email ..., you'll see an error like this:
error: error returned from database: column "nmae" does not exist
--> src/main.rs:42:13It's caught before you even commit, let alone deploy to production.
List Queries and Optional Results
For consistency, all query functions are unified under AppError.
async fn list_users(
State(state): State<AppState>,
) -> Result<Json<Vec<User>>, AppError> {
let users = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE active = $1 ORDER BY id",
true
)
.fetch_all(&state.db)
.await?;
Ok(Json(users))
}
async fn find_user_by_email(
State(state): State<AppState>,
Path(email): Path<String>,
) -> Result<Json<Option<User>>, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE email = $1",
email
)
.fetch_optional(&state.db)
.await?;
Ok(Json(user))
}fetch_one returns an error if no result is found, fetch_optional returns Option<T>, and fetch_all returns Vec<T>.
INSERT and RETURNING
#[derive(serde::Deserialize)]
struct CreateUserRequest {
name: String,
email: String,
}
async fn create_user(
State(state): State<AppState>,
Json(payload): Json<CreateUserRequest>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(
User,
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email",
payload.name,
payload.email
)
.fetch_one(&state.db)
.await?;
Ok(Json(user))
}PostgreSQL's RETURNING is used as-is. For MySQL, you would use the LAST_INSERT_ID() pattern instead.
Transactions: The Type System Enforces Commits
#[derive(serde::Deserialize)]
struct TransferRequest {
from_account_id: i64,
to_account_id: i64,
amount: i64,
}
async fn transfer(
State(state): State<AppState>,
Json(payload): Json<TransferRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let mut tx = state.db.begin().await?;
sqlx::query!(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
payload.amount,
payload.from_account_id
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
payload.amount,
payload.to_account_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(serde_json::json!({ "ok": true })))
}Here, tx is of type Transaction<'_, Postgres>. If the function returns or an error propagates via ? without calling commit(), tx drops and automatically rolls back. The mistake of forgetting ROLLBACK in the catch block when manually writing the try { BEGIN; ...; COMMIT } catch { ROLLBACK } pattern in TypeScript is eliminated at the source.
Error Handling: Minimal Information to the Client
Instead of Express's next(err), Axum handlers simply return Result<T, E>, as long as E implements IntoResponse. A common mistake here is passing DB error messages directly through to the response body. sqlx errors often contain table names, column names, and SQL structure, which can lead to information disclosure vulnerabilities.
The principle is: detailed logs, generic messages to the client.
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("database error")]
Database(#[from] sqlx::Error),
#[error("not found")]
NotFound,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
match &self {
AppError::Database(e) => {
tracing::error!(error = %e, "database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": "internal_error" })),
).into_response()
}
AppError::NotFound => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "not_found" })),
).into_response(),
}
}
}To separate sqlx::Error::RowNotFound into NotFound, you can write the From implementation manually or create a helper function. thiserror's #[from] creates automatic conversion but cannot include branching logic, so for fine-grained mapping, extracting a separate function is better.
Offline Builds in CI
The Problem
Compile-time verification becomes an obstacle in CI. cargo build tries to connect to a real DB, and if the build server has no DB, the build itself fails.
Solution: cargo sqlx prepare
Generate metadata locally (with a DB available) and commit it to the repository.
# locally (with DATABASE_URL set)
cargo sqlx prepare
# a .sqlx/ directory is created — commit this to git
git add .sqlx/
git commit -m "Update sqlx query metadata"In CI, set the environment variable to reference this cache.
SQLX_OFFLINE=true cargo build --releaseThe key thing to watch is that every time the schema changes, you need to re-run cargo sqlx prepare and commit. Forgetting this means the offline cache passes against the old schema, and you get column-not-found errors at production runtime. Many teams add it to their PR checklist or include a CI step using cargo sqlx prepare --check to verify the cache is up to date.
Connection Pool Tuning
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(5)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(600))
.max_lifetime(Duration::from_secs(1800))
.connect(&database_url)
.await?;Always set acquire_timeout. Without it, when all connections are in use, handlers block indefinitely and back-pressure is never applied.
max_connections must be considered alongside the DB server's max_connections and the number of application instances. For example, if PostgreSQL's max_connections is 100 and you have 4 app instances, about 20 per instance is the upper bound (and lower in practice, since you need to leave room for admin tools and other clients).
Trade-offs
| Item | TypeScript + Prisma/TypeORM | Rust + Axum + sqlx |
|---|---|---|
| When SQL errors are caught | Runtime | Compile time |
| How queries are written | ORM DSL or raw query | Pure SQL |
| Complex queries | Type safety lost when falling back to raw query | SQL as-is, type safety maintained |
| Transaction completion guarantee | Developer discipline | Auto-rollback on type/drop |
| Learning curve | Low | High (ownership, async ecosystem) |
| Local dev requirement | DB optional | Local DB essentially required |
| CI build | No DB needed | SQLX_OFFLINE setup required |
| Automatic relation mapping | Yes | No (write SQL directly) |
Common Pitfalls
acquire_timeoutnot set: Handlers freeze entirely during traffic spikes..sqlx/not committed: Builds break when teammates' local DBs diverge from CI.- Cache not regenerated after schema change: Offline build passes, production explodes.
max_connectionstoo high: Exceeds the DB connection limit and impacts other services.
When sqlx Is Not the Right Fit
sqlx does not have a full-featured query builder. If you need to dynamically assemble WHERE clauses based on conditions, the query_builder module can handle some of it, but at that point you move from compile-time verification to runtime verification. If this is a major requirement, it's worth exploring alternatives.
- Automatic relation mapping and a query builder are required → SeaORM
- Macro-based type safety combined with relation mapping → Diesel (async via
diesel-async) - Dynamic queries dominate → sqlx's advantages diminish
Where to Start
The appeal of the sqlx + Axum combination when moving from TypeScript to Rust is that it "pulls things you used to learn at runtime into build time." Column typos, type mismatches, and references to nonexistent tables are caught before deployment. Ownership rules also prevent the mistake of forgetting to roll back a transaction. The trade-off is that a local DB is required, an offline cache workflow needs to be established in your CI pipeline, and time is needed to get comfortable with ownership and the tokio/tower ecosystem.
If reading this post has made you want to try it yourself, here is the recommended sequence:
Step 6 is especially worth doing. Seeing exactly where and how cargo build fails the moment you introduce a typo makes it visceral why this combination is described as "reducing the need for runtime defensive code." From that point on, you will start to notice less time spent digging through 500 error logs in production.
References
- Announcing axum 0.8.0 — Tokio Official Blog
- sqlx Official GitHub (launchbadge/sqlx)
- axum sqlx-postgres Official Example — tokio-rs/axum
- PoolOptions Official API Documentation — docs.rs
- Compile-time Query Checking In-Depth Analysis — DeepWiki
- Migrating from TypeScript to Rust — corrode Rust Consulting