Migrations & Transactions
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against sqlx 0.9.0 and sqlx-cli 0.9.0 with axum 0.8.9 on Rust 1.99 (edition 2024) and PostgreSQL 16, checked on 2026-10-06. It continues Databases with sqlx. The general theory — expand/contract migrations, zero-downtime changes, isolation anomalies — is covered in the Schema Evolution pages and on SQL Transactions; this page shows the sqlx tooling.
Where it runs in the request
Migrations and transactions sit at opposite ends of the request lifecycle (tokio, hyper, tower layers, router, extractors, handler, response):
-
Migrations run before the first request.
sqlx::migrate!().run(&pool)is awaited inmain, beforeaxum::servebinds the listener to the router, so no request ever sees a half-migrated schema in that instance. With several replicas, the others keep serving the old code against the new schema during a rollout — hence additive, backwards-compatible migrations. -
Transactions live in the handler phase.
pool.begin()in a handler takes a connection at that point; theTxextractor below takes it earlier, in the extractor phase, and holds it through the handler. -
Rollback on drop covers cancellation too. If the client disconnects or a
TimeoutLayerfires, hyper or the layer drops the handler’s future; theTransactionis dropped with it and rolled back, so a cancelled request never half-commits. Work done aftercommitreturned is not undone. -
The commit happens before the response is built. A response that fails later (a serialisation error, a broken connection while streaming) does not roll back data that was already committed.
Migrations with sqlx-cli
Migrations are plain SQL files in a migrations/ directory, applied in order of their numeric prefix and recorded in
a _sqlx_migrations table together with a checksum. sqlx migrate add -r creates a reversible pair (.up.sql and
.down.sql):
$ cargo install sqlx-cli --no-default-features --features postgres,rustls
$ export DATABASE_URL=postgres://bookshelf:<password>@localhost/bookshelf
$ sqlx database create
$ sqlx migrate add -r create_loans
Creating migrations/20261006230007_create_loans.up.sql
Creating migrations/20261006230007_create_loans.down.sql
$ sqlx migrate run
Applied 20261006230007/migrate create loans (36.900146ms)
$ sqlx migrate info
20261006225711/installed create catalogue
20261006230007/installed create loans
$ sqlx migrate revert # runs the newest .down.sql
Applied 20261006230007/revert create loans (5.952092ms)
-- migrations/20261006230007_create_loans.up.sql
CREATE TABLE loans (
id BIGSERIAL PRIMARY KEY,
isbn TEXT NOT NULL REFERENCES books (isbn),
member_id BIGINT NOT NULL,
lent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
returned_at TIMESTAMPTZ
);
CREATE INDEX loans_open_idx ON loans (member_id) WHERE returned_at IS NULL;
-- migrations/20261006230007_create_loans.down.sql
DROP TABLE loans;
Source: sqlx-cli README
-
Never edit an applied migration: the checksum changes and
migrate runrefuses to continue. Add a new migration instead. -
Each migration runs in a transaction by default on Postgres. Statements that cannot run in a transaction (
CREATE INDEX CONCURRENTLY) need-- no-transactionas the first line of the file. -
Down migrations are for development; in production, roll forward with a new migration. Destructive changes follow the expand/contract pattern so old and new versions of the service can run side by side during a deployment.
Applying migrations at start-up
sqlx::migrate!() embeds the migrations/ directory into the binary at compile time, so the deployed service carries
its own schema and needs no CLI:
use sqlx::PgPool;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
// Runs every pending migration inside its own transaction; takes an advisory lock so that
// several replicas starting at once do not race.
sqlx::migrate!("./migrations").run(&pool).await?;
println!("schema up to date");
Ok(())
}
Source: sqlx: migrate!
Running migrations from the service is convenient for small deployments. Larger teams run them as a separate step — a Kubernetes Job or an init container, see Deployment — so that a failed
migration does not crash-loop every replica, and so that the application’s database user does not need DDL rights.
Add println!("cargo:rerun-if-changed=migrations"); to a build.rs (or run sqlx migrate build-script) so that
adding a migration triggers a rebuild.
Transactions
pool.begin() returns a Transaction<'static, Postgres> holding one connection. Queries run inside it by passing
&mut *tx as the executor. tx.commit() makes the changes permanent; if the transaction is dropped without commit — an early return, a ?, a panic, or the request future being cancelled by a timeout — sqlx rolls it back
automatically.
Lending a book must decrement copies and insert a loan atomically, and two members must not borrow the last copy at
the same time. The SELECT ... FOR UPDATE locks the book’s row until the transaction ends, so concurrent lenders queue
behind it:
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::post,
};
use serde::Deserialize;
use sqlx::PgPool;
const MAX_OPEN_LOANS: i64 = 5;
#[derive(Debug)]
enum LendError {
NoSuchBook,
NoCopiesLeft,
TooManyLoans,
Db(sqlx::Error),
}
impl From<sqlx::Error> for LendError {
fn from(e: sqlx::Error) -> Self {
LendError::Db(e)
}
}
impl IntoResponse for LendError {
fn into_response(self) -> Response {
match self {
LendError::NoSuchBook => (StatusCode::NOT_FOUND, "no such book\n").into_response(),
LendError::NoCopiesLeft => (StatusCode::CONFLICT, "no copies left\n").into_response(),
LendError::TooManyLoans => (StatusCode::CONFLICT, "loan limit reached\n").into_response(),
LendError::Db(err) => {
tracing::error!(%err, "lend failed");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
#[derive(Deserialize)]
struct LendRequest {
member_id: i64,
}
async fn lend(
State(pool): State<PgPool>,
Path(isbn): Path<String>,
Json(req): Json<LendRequest>,
) -> Result<(StatusCode, String), LendError> {
let mut tx = pool.begin().await?;
let copies = sqlx::query_scalar!("SELECT copies FROM books WHERE isbn = $1 FOR UPDATE", isbn)
.fetch_optional(&mut *tx)
.await?
.ok_or(LendError::NoSuchBook)?; // `?` drops tx: ROLLBACK
if copies == 0 {
return Err(LendError::NoCopiesLeft);
}
let open = sqlx::query_scalar!(
r#"SELECT count(*) AS "n!" FROM loans WHERE member_id = $1 AND returned_at IS NULL"#,
req.member_id
)
.fetch_one(&mut *tx)
.await?;
if open >= MAX_OPEN_LOANS {
return Err(LendError::TooManyLoans);
}
sqlx::query!("UPDATE books SET copies = copies - 1 WHERE isbn = $1", isbn).execute(&mut *tx).await?;
let loan_id =
sqlx::query_scalar!("INSERT INTO loans (isbn, member_id) VALUES ($1, $2) RETURNING id", isbn, req.member_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok((StatusCode::CREATED, format!("loan {loan_id}\n")))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let app = Router::new().route("/api/v1/books/{isbn}/loans", post(lend)).with_state(pool);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Source: sqlx: Transaction
$ curl -s -H 'content-type: application/json' -d '{"member_id":42}' localhost:3000/api/v1/books/9780593099322/loans
loan 1
$ curl -s -H 'content-type: application/json' -d '{"member_id":43}' localhost:3000/api/v1/books/9780593099322/loans
no copies left
-
&mut *txreborrows the transaction’s connection as an executor; passing&mut txdoes not compile. Pass&mut *tx(ortx.as_mut()) to helper functions that takeimpl PgExecutor<'_>so the same repository code works inside and outside transactions. -
pool.begin()andtx.commit()are themselves.awaitpoints; keep anything slow — HTTP calls, password hashing, sending mail — outside the transaction. -
Nested
begin()on a transaction creates aSAVEPOINT.
A per-request transaction extractor
When most handlers of a router write data, a Tx extractor removes the begin() boilerplate. The handler receives an
open transaction and commits it explicitly; a handler that returns early without committing rolls back:
use axum::{
Router,
extract::{FromRef, FromRequestParts, Path},
http::{StatusCode, request::Parts},
routing::post,
};
use sqlx::{PgPool, Postgres, Transaction};
/// An open transaction for the duration of the handler.
struct Tx(Transaction<'static, Postgres>);
impl<S> FromRequestParts<S> for Tx
where
S: Send + Sync,
PgPool: FromRef<S>,
{
type Rejection = StatusCode;
async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let pool = PgPool::from_ref(state);
pool.begin().await.map(Tx).map_err(|_| StatusCode::SERVICE_UNAVAILABLE)
}
}
async fn return_book(Tx(mut tx): Tx, Path(loan_id): Path<i64>) -> Result<StatusCode, StatusCode> {
let isbn = sqlx::query_scalar!(
"UPDATE loans SET returned_at = now() WHERE id = $1 AND returned_at IS NULL RETURNING isbn",
loan_id
)
.fetch_optional(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
sqlx::query!("UPDATE books SET copies = copies + 1 WHERE isbn = $1", isbn)
.execute(&mut *tx)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let app = Router::new().route("/api/v1/loans/{id}/return", post(return_book)).with_state(pool);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
The trade-off: the transaction (and its pooled connection) is held from extraction until the handler finishes,
including time spent in other extractors and in non-database work. Put Tx last among the parts extractors so cheap
rejections happen first, and do not use it on endpoints that call external services. The axum-sqlx-tx crate
implements a fuller version that commits automatically on a successful response.
Isolation levels and locking
Postgres defaults to READ COMMITTED: each statement sees data committed before it started. That is enough for the
examples above because FOR UPDATE serialises access to the contested row. Alternatives:
| Technique | Use when |
|---|---|
|
a few known rows must not change between read and write (pessimistic). Lock rows in a consistent order (e.g. by primary key) to avoid deadlocks. |
|
workers taking jobs from a queue table (see Background Jobs & Tasks) |
optimistic locking ( |
conflicts are rare and the read happens in a different request (an edit form) |
|
complex invariants across many rows; the database aborts conflicting
transactions with SQLSTATE |
Run sqlx::query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE").execute(&mut *tx) as the first statement of the
transaction to change its level, and wrap the whole transaction in a retry loop that retries on 40001 (serialisation
failure) and 40P01 (deadlock) a few times with jitter.
Optimistic locking
The edit form of Bookshelf reads a book in one request and saves it in another, minutes later; holding a lock in
between is impossible. Instead, every update checks and bumps the version column, and "0 rows affected" means
someone else saved first:
use sqlx::PgPool;
/// Returns Ok(Some(new_version)) on success, Ok(None) if the version was stale.
async fn rename_book(pool: &PgPool, isbn: &str, title: &str, expected_version: i64) -> sqlx::Result<Option<i64>> {
sqlx::query_scalar!(
"UPDATE books SET title = $1, version = version + 1 WHERE isbn = $2 AND version = $3 RETURNING version",
title,
isbn,
expected_version
)
.fetch_optional(pool)
.await
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let pool = PgPool::connect(&std::env::var("DATABASE_URL")?).await?;
let current: i64 = sqlx::query_scalar!("SELECT version FROM books WHERE isbn = '9780441478125'").fetch_one(&pool).await?;
println!("{:?}", rename_book(&pool, "9780441478125", "The Left Hand of Darkness", current).await?); // Some(v+1)
println!("{:?}", rename_book(&pool, "9780441478125", "The Left Hand of Darkness", current).await?); // None: stale
Ok(())
}
The HTTP side — ETag from the version, If-Match on PUT, 412 Precondition Failed — is on
JSON REST APIs.
Keep transactions short
A transaction holds a pooled connection and, with FOR UPDATE, row locks for its whole duration. Gangappa & Rangaraj
(Mastering Distributed Observability in Rust, chapter 11) trace pool exhaustion incidents back to transactions that
span an external HTTP call: every slow call pins a connection, the pool empties, and unrelated requests start timing out
in acquire. Rules that follow:
-
do all reads and validation that do not need locks before
begin(); -
never
.awaitnetwork calls, message publishing orspawn_blockingwork inside a transaction — publish events with a transactional outbox table instead; -
set
statement_timeoutandidle_in_transaction_session_timeouton the database role as a safety net; -
trace
beginandcommitand watch transaction duration in your dashboards.
Lock contention, lock_timeout and work queues with FOR UPDATE SKIP LOCKED are covered on
Databases with sqlx.
Where the books are outdated
Gangappa & Rangaraj use sqlx 0.8, where Transaction already rolled back on drop; in 0.9 the API is the same, but
runtime-built SQL passed to sqlx::query requires AssertSqlSafe and Migrator::set_ignore_missing/set_locking
now return &mut Self. Yoon (Beginning Axum, chapter 3) manages the schema with SeaORM’s migrator instead, see
SeaORM & Diesel.