Guide contents

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.

Terminal
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.toml

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-tokio and postgres select the async runtime and the driver.
  • migrate enables migrations, and with macros it provides sqlx::migrate!. macros also provides query! and query_as!. derive provides #[derive(FromRow)].
  • chrono maps TIMESTAMPTZ to chrono::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

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

Rust
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(())
}
Output
products: 0
  • max_connections bounds open connections. Extra requests wait.
  • acquire_timeout bounds the wait. On expiry the query fails with sqlx::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

Rust
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_one returns one row and fails with RowNotFound when there is none.
  • fetch_optional returns Option.
  • fetch_all returns a Vec of every row, so it needs a LIMIT.
  • fetch returns 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

Rust
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(())
}
Output
Some(("NB-1", "Notebook", 450))
listed: 1
deleted: true
deleted again: false

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

Rust
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

Rust
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(())
}
Output
NB-1 Notebook 450
  • cargo sqlx prepare (from sqlx-cli) saves the query metadata in a .sqlx directory. With SQLX_OFFLINE=true the macros read those files, so CI builds without a database. The directory is committed, and cargo sqlx prepare --check detects 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.

Text
storefront-sqlx/
  Cargo.toml
  migrations/
    20260929120000_create_products.sql
  src/
    main.rs
    db.rs
    update.rs
    error.rs

Cargo.toml

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

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

Rust
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

Rust
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

Rust
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

Rust
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.

Terminal
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 run

Create two products, then read one and search.

Terminal
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'
Output
{"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}]
200

Update a price, then hit the error paths.

Terminal
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'
Output
{"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}]
200

Delete a product twice.

Terminal
curl -s -w '%{http_code}\n' -X DELETE localhost:3000/products/2
curl -s -w '\n%{http_code}\n' -X DELETE localhost:3000/products/2
Output
204
{"code":"product_not_found","message":"product 2 not found"}
404

Summary

  • Migrations version the schema, run once each, and are never edited after they are applied.
  • A cloned PgPool in AppState shares a bounded set of connections. Pool size across instances stays below the database limit.
  • query, query_as, and query_scalar run SQL with bound parameters. FromRow maps rows to structs by column name.
  • RETURNING, COALESCE, and QueryBuilder cover 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 .sqlx metadata.