Oct 23, 20268 min read

How to Add a Postgres Database to an Axum API

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:

  • Which library talks to Postgres, whether that is raw SQL, a query builder or an ORM.
  • How the pool reaches your handlers, which is almost always through Axum State.
  • How migrations run locally and in CI.
  • Where the database comes from locally and in tests.
  • How the production database is provisioned and connected.

Postgres options for Axum at a glance

OptionApproachBest fit
EncoreDatabase declared in code, provisioned locally and in your cloudTeams that want the local and production database handled from the same code
SQLxAsync SQL toolkit with compile-time checked queriesMost Axum APIs; teams that like writing SQL
DieselType-safe query builder and ORM (async via diesel-async)Teams that want schema types and a query DSL
SeaORMAsync ORM built on SQLxTeams that want ActiveRecord-style entities
tokio-postgres + deadpoolLow-level async driver with a poolFull control over connections and queries
Compare features across all five options
CapabilityEncoreSQLxDieselSeaORMtokio-postgres + deadpool
Query styleRaw SQL with query_one, query_opt and executeRaw SQL, compile-time checked macrosRust query DSLEntities and query builderRaw SQL
AsyncYesYesVia diesel-asyncYesYes
MigrationsApplied by Encoresqlx migrate / sqlx::migrate!diesel migrationsea-orm-cli migrateBring your own
Local databaseencore run starts itYou run it (Docker, local install)You run itYou run itYou run it
Test databasesencore test gives each test its own database#[sqlx::test] creates one per testYou manage itYou manage itYou manage it
Production databaseProvisioned in your AWS or GCP accountYou provision it (RDS, Cloud SQL, etc.)You provision itYou provision itYou provision it

Encore: the database declared in your Rust code

Encore

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(); }
The database is a static declaration that handlers use directly, while Axum keeps handling routing and extraction.

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.

Key features

  • The database and its migrations are declared in Rust next to the code that uses them.
  • encore run starts Postgres locally in Docker and applies migrations automatically.
  • Each test runs against its own migrated database under encore test.
  • Production databases are provisioned in your AWS or GCP account from the same declaration.

Good to know

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.

Try Encore →

SQLx: the common choice

SQLx

SQLx 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(()) }
The pool is created at startup, migrations are applied and the pool is shared with handlers through State. The route syntax is for Axum 0.8, and Axum 0.7 uses /users/:id.

For local development you also need a database running:

~/api
$ docker run -d --name pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:17$ echo "DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres" > .env$ cargo install sqlx-cli --no-default-features --features postgres$ sqlx migrate add create_users$ sqlx migrate run

Key features

  • Queries are plain SQL with optional compile-time checking through query! and query_as!.
  • The pool, migrations and #[sqlx::test] for per-test databases are built in.
  • Offline mode (cargo sqlx prepare) lets CI build without a database.

Good to know

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.

Try SQLx →

Diesel: a typed query builder

Diesel

Diesel generates a Rust schema from your database and gives you a query DSL that the compiler checks against it.

Key features

  • The Rust type system checks queries against the generated schema.
  • The diesel migration CLI manages migrations.
  • diesel-async adds async connections and pools.

Good to know

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.

Try Diesel →

SeaORM: an async ORM

SeaORM

SeaORM is an async ORM built on SQLx, with entities generated from your schema.

Key features

  • It models data as entities and relations with an ActiveModel API.
  • sea-orm-cli generates entities and runs migrations.
  • It is async throughout, so it fits Axum handlers directly.

Good to know

SeaORM adds a layer of abstraction over SQL, so if you want to write queries by hand, SQLx on its own is simpler.

Try SeaORM →

tokio-postgres + deadpool: the low-level route

tokio-postgres

tokio-postgres is the async Postgres driver, and deadpool-postgres adds connection pooling.

Key features

  • You get direct control over connections, statements and types.
  • The dependency footprint is small.

Good to know

Migrations, compile-time checks and test helpers are not included, so most teams add refinery or a similar crate for migrations.

Try tokio-postgres →

Taking it to production

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.

How to choose

  • SQLx for most Axum APIs.
  • Diesel if you want a type-checked query DSL.
  • SeaORM if you want an async ORM with entities.
  • tokio-postgres + deadpool if you want full control over connections and queries.
  • Encore if you want the local and production database handled from the same code.

Rust support in Encore is in beta, sign up for access to use it with your Axum application.

Frequently asked questions

What is the best way to connect Axum to Postgres?

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.

How do I share a database pool between Axum handlers?

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 vs Diesel for Axum: which should I use?

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.

How do I run Postgres locally for Rust development?

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.

How do I run migrations in an Axum app?

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.

How do I test Axum handlers that use the database?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders