rust 102 lines · 3 tabs

Sequential Schema Migrations With Version Tracking in Rust and Postgres

Shared by codesnips Aug 2026
3 tabs
use std::fs;
use std::path::Path;

#[derive(Debug, Clone)]
pub struct Migration {
    pub version: i64,
    pub name: String,
    pub up: String,
}

pub fn discover(dir: &Path) -> anyhow::Result<Vec<Migration>> {
    let mut migrations = Vec::new();
    for entry in fs::read_dir(dir)? {
        let path = entry?.path();
        if path.extension().and_then(|e| e.to_str()) != Some("sql") {
            continue;
        }
        let stem = path
            .file_stem()
            .and_then(|s| s.to_str())
            .ok_or_else(|| anyhow::anyhow!("invalid filename"))?;
        let (version_str, name) = stem
            .split_once('_')
            .ok_or_else(|| anyhow::anyhow!("expected <version>_<name>.sql, got {stem}"))?;
        let version: i64 = version_str
            .parse()
            .map_err(|_| anyhow::anyhow!("non-numeric version in {stem}"))?;
        let up = fs::read_to_string(&path)?;
        migrations.push(Migration { version, name: name.to_string(), up });
    }
    migrations.sort_by_key(|m| m.version);
    Ok(migrations)
}
3 files · rust Explain with highlit

This snippet shows a minimal but real migration runner for Postgres built on tokio-postgres. The core idea is the ledger pattern: a dedicated table records which migration versions have already been applied, and the runner only executes the ones missing from that ledger, always in ascending version order. This makes running migrations idempotent — invoking it repeatedly is safe because already-applied versions are skipped — which is exactly the property needed when several app instances boot at once or a deploy is retried.

In migration.rs, a Migration is just a numeric version, a human name, and the raw up SQL. The discover function walks a directory, parses filenames like 001_create_users.sql into a version and name, and returns the list sorted by version. Sorting up front means the caller never has to reason about filesystem ordering, which is not guaranteed to be numeric. Parsing is deliberately strict: a filename that does not split into version_name.sql is rejected rather than silently ignored, so a typo cannot cause a migration to be skipped.

In runner.rs, ensure_ledger creates the schema_migrations table with IF NOT EXISTS, so the very first run bootstraps its own bookkeeping. applied_versions reads the current set into a HashSet for cheap membership checks. The heart is run_pending: it filters the discovered migrations against that set, then for each missing one opens a transaction, executes the up SQL as a batch, inserts the version row, and commits. Wrapping each migration in its own transaction is the key reliability decision — either the DDL and the ledger insert both land, or neither does, so the runner can never mark a version applied without the schema change actually succeeding. On failure the transaction is dropped and rolls back, and the error propagates so the process exits non-zero.

In main.rs, the pieces are wired together: connect, spawn the connection task that tokio-postgres requires, discover, and run. Note the trade-off — migrations run serially and take no global lock here, so in a multi-instance deploy an advisory lock around run_pending would be a sensible addition to avoid two runners racing on the same pending version.


Related snips

Share this code

Here's the card — post it anywhere.

Sequential Schema Migrations With Version Tracking in Rust and Postgres — share card
Link copied