⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1454+5faa79730On an older Zig?

Migrations as Data

The problem

Schemas change after they ship. The discipline every mature ORM converges on: each change is a versioned migration with an up and a down, the list is append-only, and the database records which version it is at. The interesting engineering question is where the logic lives, because “compute what to run” tends to get tangled into “run it”, and then nothing is testable without a database.

The pattern

  1. The history is data: an ordered array of {version, up, down}. Released entries are never edited, because someone’s database already ran them.
  2. The planner is a pure function: plan(current, target) returns the steps to execute, in order. Upgrades walk forward; downgrades walk the affected range newest-first.
  3. Executing the plan is a separate, boring loop that belongs to the repo.
const std = @import("std");
const expect = std.testing.expect;

const Migration = struct {
    version: u32,
    up: []const u8,
    down: []const u8,
};

// The whole history, in order. Append-only: released migrations are
// never edited, because someone's database already ran them.
const history = [_]Migration{
    .{
        .version = 1,
        .up = "CREATE TABLE users (id INTEGER, name TEXT)",
        .down = "DROP TABLE users",
    },
    .{
        .version = 2,
        .up = "CREATE TABLE posts (id INTEGER, title TEXT)",
        .down = "DROP TABLE posts",
    },
    .{
        .version = 3,
        .up = "ALTER TABLE users ADD COLUMN active INTEGER",
        .down = "ALTER TABLE users DROP COLUMN active",
    },
};

const Step = struct { version: u32, sql: []const u8 };

// The planner is a pure function from (current, target) to steps. All
// the subtle cases (no-op, partial upgrade, rollback ordering) are
// testable without a database anywhere in sight.
fn plan(current: u32, target: u32, buf: []Step) []Step {
    var n: usize = 0;
    if (target > current) {
        for (history) |m| {
            if (m.version > current and m.version <= target) {
                buf[n] = .{ .version = m.version, .sql = m.up };
                n += 1;
            }
        }
    } else {
        // Downgrades run the down steps newest-first.
        var i = history.len;
        while (i > 0) {
            i -= 1;
            const m = history[i];
            if (m.version <= current and m.version > target) {
                buf[n] = .{ .version = m.version, .sql = m.down };
                n += 1;
            }
        }
    }
    return buf[0..n];
}

test "a fresh database applies everything in order" {
    var buf: [8]Step = undefined;
    const steps = plan(0, 3, &buf);
    try expect(steps.len == 3);
    try expect(steps[0].version == 1);
    try expect(steps[2].version == 3);
    try expect(std.mem.startsWith(u8, steps[0].sql, "CREATE TABLE users"));
}

test "partial upgrade takes only the gap" {
    var buf: [8]Step = undefined;
    const steps = plan(1, 3, &buf);
    try expect(steps.len == 2);
    try expect(steps[0].version == 2);
    try expect(steps[1].version == 3);
}

test "rollback runs down steps newest-first" {
    var buf: [8]Step = undefined;
    const steps = plan(3, 1, &buf);
    try expect(steps.len == 2);
    try expect(steps[0].version == 3);
    try expect(std.mem.startsWith(u8, steps[0].sql, "ALTER TABLE users DROP"));
    try expect(steps[1].version == 2);
}

test "already there is a no-op" {
    var buf: [8]Step = undefined;
    try expect(plan(3, 3, &buf).len == 0);
}

Purity is what makes the edge cases cheap

The four tests cover the cases that go wrong in real systems: the fresh database, the partial upgrade, the rollback (in reverse order; forward order corrupts), and the no-op. Each is an assertion about a function from two integers to a slice. No fixtures, no containers, no cleanup. When the planner and the executor are one function, every one of those tests needs a live database and most never get written.

The split also buys --dry-run for free: print the plan instead of executing it. Any operation your library can plan without performing is one users can review before trusting.

Append-only is a social contract, not a technical one

Nothing in the code stops someone editing migration 2 after release; the guarantee is convention plus review. A library can help enforce it: the draft stores a checksum of each applied migration and refuses to proceed when history was rewritten under it. That check is, again, pure logic over data, testable the same way.

In a full ORM

The draft’s migrations are Zig files discovered by the build (the timestamped files in sample-migrations/), each exposing up and down, with a CLI that creates, applies, and rolls back. The applied version lives in a schema_migrations table; reading it and executing the plan are the only parts that touch the database.