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 |
Diffing a change
Section titled “Diffing a change”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.
Destructive changes are commented out
Section titled “Destructive changes are commented out”changes, _ := migrate.Diff(current, target) // a column was removed from the schemaALTER TABLE "posts" DROP COLUMN "legacy_slug";destructive: truereason: dropping posts.legacy_slug deletes its contents. The Down restores the column but not the valuesRendering 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.
Renames are declared, never inferred
Section titled “Renames are declared, never inferred”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.
Rendering files
Section titled “Rendering files”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_countALTER TABLE "posts" ADD COLUMN "view_count" bigint NOT NULL DEFAULT 0;
-- +goose DownALTER 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.
Which Postgres
Section titled “Which Postgres”There is no hard minimum; the generated DDL is mostly SQL that has been valid for a decade. Three places are version sensitive:
schema.GenUUIDv7emitsuuid_generate_v7(), which needs thepg_uuidv7extension — so by default a UUIDv7 primary key produces DDL that will not apply to a stock install. Postgres 18 hasuuidv7()built in: passmigrate.MinPostgres(18)toDiffand it emits that instead. On an older server without the extension, useschema.GenUUIDv4, built in since Postgres 13.example/taskspassesMinPostgres(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’sSET NOT NULLsequence is correct on any version but only fast from Postgres 12.introspecthandles theNOT NULLconstraint 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