Skip to content

Migrations

sqlb does not apply migrations and does not track which have run. You already have a runner — goose, golang-migrate, atlas, a shell script — and replacing a working one is a far larger ask than adopting a code generator, for no benefit sqlb could offer. This package produces files; your runner applies them.

There are three separable layers, and the separation is deliberate:

Layer Knows about
Diff Two schema registries. A pure function; no database, nothing applied
The DDL layer Postgres
A Format goose, or golang-migrate, or plain files

You edited a schema file. Diff tells you what that means in DDL:

changes, err := migrate.Diff(current, target)
for _, c := range changes {
fmt.Println(c.Up)
fmt.Println(c.Down)
}
ALTER TABLE "posts" ADD COLUMN "view_count" bigint NOT NULL DEFAULT 0;
ALTER TABLE "posts" DROP COLUMN "view_count";

target is your declared schema — schema.DefaultRegistry(). Where current comes from is the interesting question, and there are three answers; see Where “current” comes from.

changes, _ := migrate.Diff(current, target) // a column was removed from the schema
ALTER TABLE "posts" DROP COLUMN "legacy_slug";
destructive: true
reason: dropping posts.legacy_slug deletes its contents. The Down restores the column but not the values

Rendering emits a destructive change commented out, so applying it is a deliberate act rather than something a generator did on your behalf. Pass Options{AllowDestructive: true} when you mean it — a flag with a decision attached to it.

Anything depending on a commented-out change is commented out too, and comes back live when that one does. Otherwise the file would not be a reviewable no-op but a migration that fails partway through, with a constraint naming a column the skipped statement never added.

A rename is indistinguishable from a drop plus an add when only the before and after states are known, and inferring one from a similar name and type destroys data whenever the inference is wrong. So you declare it (ADR-0014):

schema.Text("email_address").RenamedFrom("email")
ALTER TABLE "authors" RENAME COLUMN "email" TO "email_address";

The hint is needed for exactly one release: the migration is generated once, and after that the old name is gone from every database it has been applied to. A hint whose old column no longer exists is ignored, so leaving one behind is harmless — but delete it at your next edit, because a stale hint reads as a claim about the current schema that is no longer true.

TableDef.RenamedFrom does the same for a whole table.

m := migrate.Migration{
Version: migrate.TimestampVersion(time.Now()),
Name: "add_view_count",
Changes: changes,
}
files, err := migrate.Write("db/migrations", m, migrate.Options{Format: migrate.Goose})

Render returns the files in memory if you want to inspect before writing. Goose is the default because its single-file Up/Down format is the one most likely to be pasted into by hand afterwards; GolangMigrate and Plain are also available, and ByName resolves a format from a string.

-- 20260727120000_add_view_count.sql
-- Generated by sqlb. Review before applying.
-- +goose Up
-- posts.view_count
ALTER TABLE "posts" ADD COLUMN "view_count" bigint NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE "posts" DROP COLUMN "view_count";

SequentialVersion(n) is there if your runner numbers migrations rather than timestamping them.

An empty Down renders a comment explaining that the change is not automatically reversible, rather than a silently missing section — a Down that does nothing is worse than one that says why.

example/tasks/cmd/migrate is a worked version of this page: a baseline diffed from an empty registry, then a second migration of hand-written migrate.Change values for what the DSL cannot express — trigger functions for updated_at and for a completed_at that has to agree with a check constraint, plus a pair of composite foreign keys. Changes is an ordinary slice, so the escape hatch is append rather than a fork, and hand-written SQL is rendered, ordered and split by the same code as generated SQL. A multi-statement body gets goose’s StatementBegin/StatementEnd without being asked.

It also shows the cost of Write refusing to overwrite — right, because a migration already applied somewhere must not change under the runner’s feet. Regenerating during development means deleting first, and that example deletes only the files Render says it is about to write.

There is no hard minimum; the generated DDL is mostly SQL that has been valid for a decade. Three places are version sensitive:

  • schema.GenUUIDv7 emits uuid_generate_v7(), which needs the pg_uuidv7 extension — so by default a UUIDv7 primary key produces DDL that will not apply to a stock install. Postgres 18 has uuidv7() built in: pass migrate.MinPostgres(18) to Diff and it emits that instead. On an older server without the extension, use schema.GenUUIDv4, built in since Postgres 13. example/tasks passes MinPostgres(18) for exactly this reason, which is also why it needs 18 — a demo requiring an extension before it will run is not much of a demo.
  • Unblock’s SET NOT NULL sequence is correct on any version but only fast from Postgres 12.
  • introspect handles the NOT NULL constraint rows Postgres 18 introduced, and ignores them on older versions.
  • Locks and rollout — the statements that block writers, and what rewrites them
  • Adopting a database — where the “current” side of a diff comes from, and importing a schema you already have
  • Refactoring a database — a schema that changed five times, worked through: what is free, what destroys data, and the rename that is a clean migration and a broken client at once
  • Declaring tables — the declarations these diffs are computed from
  • ADR-0014 — why renames are declared, and why the history beats production as a source of truth