Lesson 6 of 30
PostgreSQL with SQLx: Migrations, Pools, and CRUD
SQLx is an async SQL toolkit for Rust. The service writes SQL, and SQLx sends it, binds parameters, and converts rows into Rust types. This lesson connects Storefront to PostgreSQL and implements create, read, update, and delete. It assumes SQL introduction and Data types and constraints.
Project setup
Start PostgreSQL in Docker and set the connection URL. Every example reads it from DATABASE_URL.
docker run --name storefront-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=storefront -p 5432:5432 -d postgres:16
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/storefrontCargo.toml
[package]
name = "storefront-sqlx"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "migrate", "macros", "derive", "chrono"] }
thiserror = "2"
tokio = { version = "1.44", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"runtime-tokioandpostgresselect the async runtime and the driver.migrateenables migrations, and withmacrosit providessqlx::migrate!.macrosalso providesquery!andquery_as!.deriveprovides#[derive(FromRow)].chronomapsTIMESTAMPTZtochrono::DateTime<Utc>.
Example 1: Migrations and a pool
A migration is a SQL file with a version prefix. SQLx records applied migrations in the table _sqlx_migrations and applies each once, in order. Create the file migrations/20260929120000_create_products.sql (the sqlx migrate add create_products command of sqlx-cli generates the name).
migrations/20260929120000_create_products.sql
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 100),
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);The constraints repeat the rules from REST API design. The API validates for good messages, and the database enforces the rules for every writer.
A pool keeps open connections and lends one per query, because opening a connection is expensive and each connection serves one query at a time. PgPool is a cheap-to-clone handle. sqlx::migrate! embeds the migrations directory in the binary, and run applies the missing files.
src/main.rs
use std::time::Duration;
use sqlx::postgres::PgPoolOptions;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let pool = PgPoolOptions::new()
.max_connections(10)
.min_connections(2)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(300))
.connect(&database_url)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM products")
.fetch_one(&pool)
.await?;
println!("products: {count}");
Ok(())
}products: 0max_connectionsbounds open connections. Extra requests wait.acquire_timeoutbounds the wait. On expiry the query fails withsqlx::Error::PoolTimedOut, which maps to 503 because the cause is load.- The total pool size across all instances must stay below the database
max_connections(100 by default in PostgreSQL). A pooler such as PgBouncer sits between services and the database when the total is large. - Applied migrations are never edited. SQLx stores a checksum and refuses to run a changed file.
- SQLx holds an advisory lock while migrating, so instances that start together wait for one another.
Example 2: Insert, read, list, delete
sqlx::query_as maps each row to a struct that derives FromRow, which matches columns to fields by name. bind sends values separately from the SQL text, so the database never parses them as SQL. This prevents SQL injection, and format! with user input does not. RETURNING returns the columns of the written row, so an insert needs no second query.
src/db.rs
use std::time::Duration;
use chrono::{DateTime, Utc};
use sqlx::{postgres::PgPoolOptions, PgPool};
#[derive(Debug, sqlx::FromRow)]
pub struct Product {
pub id: i64,
pub sku: String,
pub name: String,
pub price_cents: i32,
pub created_at: DateTime<Utc>,
}
pub async fn connect(database_url: &str) -> Result<PgPool, sqlx::Error> {
PgPoolOptions::new()
.max_connections(10)
.min_connections(2)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(300))
.connect(database_url)
.await
}
pub async fn insert(pool: &PgPool, sku: &str, name: &str, price_cents: i32) -> Result<Product, sqlx::Error> {
sqlx::query_as::<_, Product>(
"INSERT INTO products (sku, name, price_cents) VALUES ($1, $2, $3)
RETURNING id, sku, name, price_cents, created_at",
)
.bind(sku)
.bind(name)
.bind(price_cents)
.fetch_one(pool)
.await
}
pub async fn find(pool: &PgPool, id: i64) -> Result<Option<Product>, sqlx::Error> {
sqlx::query_as::<_, Product>(
"SELECT id, sku, name, price_cents, created_at FROM products WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn list(pool: &PgPool, limit: i64) -> Result<Vec<Product>, sqlx::Error> {
sqlx::query_as::<_, Product>(
"SELECT id, sku, name, price_cents, created_at FROM products ORDER BY id LIMIT $1",
)
.bind(limit)
.fetch_all(pool)
.await
}
pub async fn delete(pool: &PgPool, id: i64) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM products WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}The fetch method sets how many rows the call expects:
fetch_onereturns one row and fails withRowNotFoundwhen there is none.fetch_optionalreturnsOption.fetch_allreturns aVecof every row, so it needs aLIMIT.fetchreturns a stream that processes one row at a time.
execute returns a PgQueryResult, and rows_affected() says whether a DELETE matched a row. Deleting a missing row is not a database error, so that count is the signal for a 404.
Named columns instead of SELECT * keep the mapping stable when a table gains a column. Replace src/main.rs with this program and run it on an empty table.
src/main.rs
mod db;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = db::connect(&std::env::var("DATABASE_URL")?).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let created = db::insert(&pool, "NB-1", "Notebook", 450).await?;
let found = db::find(&pool, created.id).await?;
println!("{:?}", found.map(|p| (p.sku, p.name, p.price_cents)));
let all = db::list(&pool, 10).await?;
println!("listed: {}", all.len());
println!("deleted: {}", db::delete(&pool, created.id).await?);
println!("deleted again: {}", db::delete(&pool, created.id).await?);
Ok(())
}Some(("NB-1", "Notebook", 450))
listed: 1
deleted: true
deleted again: falseExample 3: Partial update and dynamic search
COALESCE returns its first non-null argument. Binding None sends SQL NULL, so COALESCE($2, name) keeps the current value when the client omitted the field. This pattern cannot set a column to NULL.
QueryBuilder assembles SQL whose shape depends on the input, such as optional filters. push appends trusted SQL written by the developer. push_bind adds a placeholder and a bound value, so user input goes only through push_bind.
src/update.rs
use sqlx::{PgPool, Postgres, QueryBuilder};
use crate::db::Product;
pub async fn update(
pool: &PgPool,
id: i64,
name: Option<&str>,
price_cents: Option<i32>,
) -> Result<Option<Product>, sqlx::Error> {
sqlx::query_as::<_, Product>(
"UPDATE products
SET name = COALESCE($2, name),
price_cents = COALESCE($3, price_cents)
WHERE id = $1
RETURNING id, sku, name, price_cents, created_at",
)
.bind(id)
.bind(name)
.bind(price_cents)
.fetch_optional(pool)
.await
}
pub async fn search(
pool: &PgPool,
name_contains: Option<&str>,
max_price: Option<i32>,
limit: i64,
) -> Result<Vec<Product>, sqlx::Error> {
let mut qb: QueryBuilder<Postgres> =
QueryBuilder::new("SELECT id, sku, name, price_cents, created_at FROM products WHERE TRUE");
if let Some(text) = name_contains {
qb.push(" AND name ILIKE ").push_bind(format!("%{text}%"));
}
if let Some(price) = max_price {
qb.push(" AND price_cents <= ").push_bind(price);
}
qb.push(" ORDER BY id LIMIT ").push_bind(limit);
qb.build_query_as::<Product>().fetch_all(pool).await
}update returns the updated row, or None when no row has that id. search runs with any combination of the filters. In ILIKE patterns, % and _ in user input act as wildcards.
Example 4: Type mapping and compile-time checked queries
SQLx checks type compatibility when it decodes, and a mismatch is a run-time error.
| PostgreSQL | Rust |
|---|---|
SMALLINT, INTEGER, BIGINT |
i16, i32, i64 |
REAL, DOUBLE PRECISION |
f32, f64 |
TEXT, VARCHAR |
String, &str |
BOOLEAN |
bool |
TIMESTAMPTZ |
chrono::DateTime<Utc> (feature chrono) |
UUID |
uuid::Uuid (feature uuid) |
JSONB |
sqlx::types::Json<T> (feature json) |
BYTEA |
Vec<u8> |
| Nullable column | Option<T> |
An INTEGER column cannot decode into i64 or u32, because PostgreSQL has no unsigned integers. A nullable column must decode into Option. A PostgreSQL enum type maps to a Rust enum with #[derive(sqlx::Type)] and #[sqlx(type_name = "...")]. Adding a value to an enum type needs a migration, and removing one is not supported.
query! and query_as! check SQL at compile time. The macro connects to the database in DATABASE_URL, asks PostgreSQL to prepare the statement, and generates types from the result columns. A misspelled column or a wrong parameter type becomes a compile error. Macro arguments are values (1000_i32), not bind calls, and the SQL must be a string literal.
Replace src/main.rs with this program. The table must exist, so run Example 1 first.
src/main.rs
use sqlx::PgPool;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
sqlx::query("INSERT INTO products (sku, name, price_cents) VALUES ('NB-1', 'Notebook', 450)")
.execute(&pool)
.await?;
let rows = sqlx::query!(
"SELECT sku, name, price_cents FROM products WHERE price_cents <= $1 ORDER BY id",
1000_i32
)
.fetch_all(&pool)
.await?;
for row in rows {
println!("{} {} {}", row.sku, row.name, row.price_cents);
}
Ok(())
}NB-1 Notebook 450cargo sqlx prepare(fromsqlx-cli) saves the query metadata in a.sqlxdirectory. WithSQLX_OFFLINE=truethe macros read those files, so CI builds without a database. The directory is committed, andcargo sqlx prepare --checkdetects stale metadata.- The function form (
query_as) supports dynamic SQL and needs no database at build time. The macro form checks SQL at build time and needs literal SQL.
Complete example
The service keeps SQL in src/db.rs and src/update.rs, maps errors in src/error.rs, and exposes the CRUD routes in src/main.rs. Error bodies here are small code and message objects. The error handling lesson shows the problem details format.
storefront-sqlx/
Cargo.toml
migrations/
20260929120000_create_products.sql
src/
main.rs
db.rs
update.rs
error.rsCargo.toml
[package]
name = "storefront-sqlx"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8.4"
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "migrate", "macros", "derive", "chrono"] }
thiserror = "2"
tokio = { version = "1.44", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"migrations/20260929120000_create_products.sql
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 100),
price_cents INTEGER NOT NULL CHECK (price_cents > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);src/db.rs
use std::time::Duration;
use chrono::{DateTime, Utc};
use sqlx::{postgres::PgPoolOptions, PgPool};
#[derive(Debug, sqlx::FromRow)]
pub struct Product {
pub id: i64,
pub sku: String,
pub name: String,
pub price_cents: i32,
pub created_at: DateTime<Utc>,
}
pub async fn connect(database_url: &str) -> Result<PgPool, sqlx::Error> {
PgPoolOptions::new()
.max_connections(10)
.min_connections(2)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(300))
.connect(database_url)
.await
}
pub async fn insert(pool: &PgPool, sku: &str, name: &str, price_cents: i32) -> Result<Product, sqlx::Error> {
sqlx::query_as::<_, Product>(
"INSERT INTO products (sku, name, price_cents) VALUES ($1, $2, $3)
RETURNING id, sku, name, price_cents, created_at",
)
.bind(sku)
.bind(name)
.bind(price_cents)
.fetch_one(pool)
.await
}
pub async fn find(pool: &PgPool, id: i64) -> Result<Option<Product>, sqlx::Error> {
sqlx::query_as::<_, Product>(
"SELECT id, sku, name, price_cents, created_at FROM products WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn list(pool: &PgPool, limit: i64) -> Result<Vec<Product>, sqlx::Error> {
sqlx::query_as::<_, Product>(
"SELECT id, sku, name, price_cents, created_at FROM products ORDER BY id LIMIT $1",
)
.bind(limit)
.fetch_all(pool)
.await
}
pub async fn delete(pool: &PgPool, id: i64) -> Result<bool, sqlx::Error> {
let result = sqlx::query("DELETE FROM products WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}src/update.rs
use sqlx::{PgPool, Postgres, QueryBuilder};
use crate::db::Product;
pub async fn update(
pool: &PgPool,
id: i64,
name: Option<&str>,
price_cents: Option<i32>,
) -> Result<Option<Product>, sqlx::Error> {
sqlx::query_as::<_, Product>(
"UPDATE products
SET name = COALESCE($2, name),
price_cents = COALESCE($3, price_cents)
WHERE id = $1
RETURNING id, sku, name, price_cents, created_at",
)
.bind(id)
.bind(name)
.bind(price_cents)
.fetch_optional(pool)
.await
}
pub async fn search(
pool: &PgPool,
name_contains: Option<&str>,
max_price: Option<i32>,
limit: i64,
) -> Result<Vec<Product>, sqlx::Error> {
let mut qb: QueryBuilder<Postgres> =
QueryBuilder::new("SELECT id, sku, name, price_cents, created_at FROM products WHERE TRUE");
if let Some(text) = name_contains {
qb.push(" AND name ILIKE ").push_bind(format!("%{text}%"));
}
if let Some(price) = max_price {
qb.push(" AND price_cents <= ").push_bind(price);
}
qb.push(" ORDER BY id LIMIT ").push_bind(limit);
qb.build_query_as::<Product>().fetch_all(pool).await
}src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("product {0} not found")]
NotFound(i64),
#[error("{0}")]
Validation(&'static str),
#[error("a product with this SKU already exists")]
DuplicateSku,
#[error("the database is busy, try again")]
Unavailable,
#[error("database error")]
Database(#[source] sqlx::Error),
}
impl From<sqlx::Error> for ApiError {
fn from(err: sqlx::Error) -> Self {
if matches!(&err, sqlx::Error::PoolTimedOut) {
return ApiError::Unavailable;
}
if let sqlx::Error::Database(db) = &err {
if db.is_unique_violation() {
return ApiError::DuplicateSku;
}
}
ApiError::Database(err)
}
}
#[derive(Serialize)]
struct ErrorBody {
code: &'static str,
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, code) = match &self {
ApiError::NotFound(_) => (StatusCode::NOT_FOUND, "product_not_found"),
ApiError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, "validation_failed"),
ApiError::DuplicateSku => (StatusCode::CONFLICT, "duplicate_sku"),
ApiError::Unavailable => (StatusCode::SERVICE_UNAVAILABLE, "database_busy"),
ApiError::Database(_) => {
tracing::error!(error = ?self, "request failed");
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
}
};
let message = match &self {
ApiError::Database(_) => "the server could not complete the request".to_owned(),
other => other.to_string(),
};
(status, Json(ErrorBody { code, message })).into_response()
}
}src/main.rs
mod db;
mod error;
mod update;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use error::ApiError;
#[derive(Clone)]
struct AppState {
pool: PgPool,
}
#[derive(Serialize)]
struct ProductDto {
id: i64,
sku: String,
name: String,
price_cents: i32,
}
impl From<db::Product> for ProductDto {
fn from(p: db::Product) -> Self {
ProductDto { id: p.id, sku: p.sku, name: p.name, price_cents: p.price_cents }
}
}
#[derive(Deserialize)]
struct CreateProduct {
sku: String,
name: String,
price_cents: i32,
}
#[derive(Deserialize)]
struct PatchProduct {
name: Option<String>,
price_cents: Option<i32>,
}
#[derive(Deserialize)]
struct SearchParams {
q: Option<String>,
max_price: Option<i32>,
limit: Option<i64>,
}
fn check(name: Option<&str>, price_cents: Option<i32>) -> Result<(), ApiError> {
if let Some(name) = name {
if name.is_empty() || name.chars().count() > 100 {
return Err(ApiError::Validation("name must be 1 to 100 characters"));
}
}
if let Some(price) = price_cents {
if price <= 0 {
return Err(ApiError::Validation("price_cents must be greater than 0"));
}
}
Ok(())
}
async fn create_product(
State(state): State<AppState>,
Json(input): Json<CreateProduct>,
) -> Result<(StatusCode, Json<ProductDto>), ApiError> {
check(Some(&input.name), Some(input.price_cents))?;
let product = db::insert(&state.pool, &input.sku, &input.name, input.price_cents).await?;
Ok((StatusCode::CREATED, Json(product.into())))
}
async fn get_product(State(state): State<AppState>, Path(id): Path<i64>) -> Result<Json<ProductDto>, ApiError> {
let product = db::find(&state.pool, id).await?.ok_or(ApiError::NotFound(id))?;
Ok(Json(product.into()))
}
async fn list_products(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Result<Json<Vec<ProductDto>>, ApiError> {
let limit = params.limit.unwrap_or(50).clamp(1, 100);
let rows = update::search(&state.pool, params.q.as_deref(), params.max_price, limit).await?;
Ok(Json(rows.into_iter().map(ProductDto::from).collect()))
}
async fn patch_product(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<PatchProduct>,
) -> Result<Json<ProductDto>, ApiError> {
check(input.name.as_deref(), input.price_cents)?;
let product = update::update(&state.pool, id, input.name.as_deref(), input.price_cents)
.await?
.ok_or(ApiError::NotFound(id))?;
Ok(Json(product.into()))
}
async fn delete_product(State(state): State<AppState>, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
if db::delete(&state.pool, id).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::NotFound(id))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
let pool = db::connect(&std::env::var("DATABASE_URL")?).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
let app = Router::new()
.route("/products", get(list_products).post(create_product))
.route("/products/{id}", get(get_product).patch(patch_product).delete(delete_product))
.with_state(AppState { pool });
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}Start from a fresh database so the first id is 1, wait a few seconds until PostgreSQL accepts connections, then run the service.
docker rm -f storefront-pg
docker run --name storefront-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=storefront -p 5432:5432 -d postgres:16
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/storefront
cargo runCreate two products, then read one and search.
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/products -H 'content-type: application/json' -d '{"sku":"NB-1","name":"Notebook","price_cents":450}'
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/products -H 'content-type: application/json' -d '{"sku":"PN-1","name":"Pen","price_cents":150}'
curl -s -w '\n%{http_code}\n' localhost:3000/products/1
curl -s -w '\n%{http_code}\n' 'localhost:3000/products?q=pen'{"id":1,"sku":"NB-1","name":"Notebook","price_cents":450}
201
{"id":2,"sku":"PN-1","name":"Pen","price_cents":150}
201
{"id":1,"sku":"NB-1","name":"Notebook","price_cents":450}
200
[{"id":2,"sku":"PN-1","name":"Pen","price_cents":150}]
200Update a price, then hit the error paths.
curl -s -w '\n%{http_code}\n' -X PATCH localhost:3000/products/1 -H 'content-type: application/json' -d '{"price_cents":500}'
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/products -H 'content-type: application/json' -d '{"sku":"NB-1","name":"Notebook","price_cents":450}'
curl -s -w '\n%{http_code}\n' -X POST localhost:3000/products -H 'content-type: application/json' -d '{"sku":"XX-1","name":"Broken","price_cents":0}'
curl -s -w '\n%{http_code}\n' 'localhost:3000/products?max_price=500'{"id":1,"sku":"NB-1","name":"Notebook","price_cents":500}
200
{"code":"duplicate_sku","message":"a product with this SKU already exists"}
409
{"code":"validation_failed","message":"price_cents must be greater than 0"}
422
[{"id":1,"sku":"NB-1","name":"Notebook","price_cents":500},{"id":2,"sku":"PN-1","name":"Pen","price_cents":150}]
200Delete a product twice.
curl -s -w '%{http_code}\n' -X DELETE localhost:3000/products/2
curl -s -w '\n%{http_code}\n' -X DELETE localhost:3000/products/2204
{"code":"product_not_found","message":"product 2 not found"}
404Summary
- Migrations version the schema, run once each, and are never edited after they are applied.
- A cloned
PgPoolinAppStateshares a bounded set of connections. Pool size across instances stays below the database limit. query,query_as, andquery_scalarrun SQL with bound parameters.FromRowmaps rows to structs by column name.RETURNING,COALESCE, andQueryBuildercover inserts, partial updates, and dynamic filters.- Type mapping is strict: nullable columns need
Option, and PostgreSQL has no unsigned integers. query!macros check SQL at compile time against a live database or committed.sqlxmetadata.
