Connecting Axum to PostgreSQL with SQLx, Diesel, SeaORM, tokio-postgres, or Encore, plus how to handle local databases, migrations, tests, and production.
Axum is the Rust web framework from the Tokio team, and it handles routing, extractors and shared state but leaves the database layer to you. Adding Postgres means picking a driver or ORM, passing a connection pool to handlers through State, running migrations and keeping a database running locally and in tests, and each of those steps has its own set of libraries to choose between. This guide walks through Encore, SQLx, Diesel, SeaORM and tokio-postgres with working code, and then covers what changes when the database goes to production.
What you need to decide:
State.Encore combines the database declaration with your application code, so a Postgres database is a static in your Rust crate that Encore finds through static analysis and then provisions, migrates and connects for you in every environment. Routing stays in Axum and the handlers query the database through the static directly, which means there is no pool to build at startup, no DATABASE_URL to manage and no State to thread through the router.
use axum::{extract::Path, http::StatusCode, routing::get, Json, Router};
use encore::storage::postgres::Database;
use serde::Serialize;
// Rust: Encore discovers this declaration, provisions the database
// and applies the migrations in ./migrations before the service starts.
static DB: Database = Database::new("users").migrations("./migrations");
#[derive(Serialize)]
struct User {
id: i64,
name: String,
}
async fn get_user(Path(id): Path<i64>) -> Result<Json<User>, StatusCode> {
let row = DB
.query_opt("SELECT id, name FROM users WHERE id = $1", &[&id])
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(User {
id: row.get("id"),
name: row.get("name"),
}))
}
#[tokio::main]
async fn main() {
encore::initialize();
let app = Router::new().route("/users/{id}", get(get_user));
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into());
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(encore::shutdown::signal())
.await
.unwrap();
}
Migrations are plain SQL files in the directory the declaration points to, numbered in the order they should run:
-- migrations/1_create_users.up.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
);
When you start the app with encore run, Encore starts Postgres in Docker, creates the users database and applies the migrations before the service starts, so there is no docker run or .env step. Writes go through DB.execute with the same parameter style, and encore test gives each test its own migrated database so tests do not see each other's rows. When you deploy, the same declaration is provisioned in your own AWS or GCP account as RDS or Cloud SQL, with the connection and credentials wired into the service for you.
encore run starts Postgres locally in Docker and applies migrations automatically.encore test.Queries are raw SQL with positional parameters, so if you want compile-time checked queries or an ORM layer you would still pair the rest of your stack with that choice. Rust support is in beta, sign up for access.
SQLxSQLx is an async SQL toolkit where you write SQL directly, and its query! macros can check those queries against a real database at compile time.
use axum::{extract::{Path, State}, http::StatusCode, routing::get, Json, Router};
use serde::Serialize;
use sqlx::{postgres::PgPoolOptions, FromRow, PgPool};
#[derive(Serialize, FromRow)]
struct User {
id: i64,
name: String,
}
async fn get_user(
State(pool): State<PgPool>,
Path(id): Path<i64>,
) -> Result<Json<User>, StatusCode> {
sqlx::query_as::<_, User>("SELECT id, name FROM users WHERE id = $1")
.bind(id)
.fetch_optional(&pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&std::env::var("DATABASE_URL")?)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
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?;
axum::serve(listener, app).await?;
Ok(())
}
/users/:id.For local development you also need a database running:
query! and query_as!.#[sqlx::test] for per-test databases are built in.cargo sqlx prepare) lets CI build without a database.The compile-time checked macros need either a reachable database or prepared query data at build time, and everything around the database, from Docker and .env to CI and the production instance with its credentials, is still yours to set up.
Diesel generates a Rust schema from your database and gives you a query DSL that the compiler checks against it.
diesel migration CLI manages migrations.Diesel's main API is synchronous, so in Axum you either use diesel-async or run queries inside tokio::task::spawn_blocking with a sync pool such as r2d2.
SeaORMSeaORM is an async ORM built on SQLx, with entities generated from your schema.
sea-orm-cli generates entities and runs migrations.SeaORM adds a layer of abstraction over SQL, so if you want to write queries by hand, SQLx on its own is simpler.
tokio-postgres is the async Postgres driver, and deadpool-postgres adds connection pooling.
Migrations, compile-time checks and test helpers are not included, so most teams add refinery or a similar crate for migrations.
With SQLx, Diesel, SeaORM or tokio-postgres, production means provisioning a managed Postgres such as RDS or Cloud SQL, putting the connection string in a secret, configuring networking so the service can reach it and running migrations in the deploy pipeline, and many teams describe that infrastructure in Terraform.
With Encore, the database declaration in the code is what gets provisioned, so a deploy to your AWS account creates an RDS database and a deploy to GCP creates Cloud SQL without separate Terraform for it, and the migrations in the declared directory are applied as part of the deploy.
Rust support in Encore is in beta, sign up for access to use it with your Axum application.
For most projects, SQLx with a PgPool stored in Axum's State. It is async, uses plain SQL, and can check queries against the database at compile time. Diesel or SeaORM fit if you want an ORM, and Encore fits if you also want the database provisioned for you locally and in your AWS or GCP account from a declaration in your Rust code.
Create the pool once at startup and pass it with Router::with_state. Handlers receive it through the State extractor. Pools like sqlx::PgPool are cheap to clone because they share the same underlying connections.
SQLx is async-first and lets you write SQL directly with compile-time checked queries. Diesel is a query builder and ORM with a strong type-level schema; its core API is synchronous, so in Axum you either use diesel-async or run queries in spawn_blocking.
The usual way is Docker or Docker Compose with a postgres image and a DATABASE_URL in .env. With Encore, encore run starts Postgres in Docker for you and applies your migrations, so there is no container or connection string to manage.
With SQLx, put SQL files in ./migrations and call sqlx::migrate!().run(&pool) at startup or run sqlx migrate run in CI. Diesel and SeaORM have their own migration CLIs, and Encore applies the SQL files in the migrations directory named in the database declaration automatically on encore run and on deploy.
SQLx has a #[sqlx::test] attribute that creates a fresh test database per test. Encore gives each test its own migrated database under encore test. Otherwise, run a test database in Docker or use Testcontainers.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.