migrate

package
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 1, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package migrate turns a schema change into migration files for an existing migration runner.

There are three layers. Diff compares two schema registries and returns the Changes between them. The DDL layer renders those changes as Postgres statements. A Format renders a set of changes as the files a particular runner expects. They are separable on purpose: the first is a pure function over two data structures, the second knows only Postgres, and the third knows only goose or golang-migrate.

sqlb does not apply migrations and does not track which have run. Projects 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.

Goose is the default because it is what this project's authors use, and because its single-file Up/Down format is the one most likely to be pasted into by hand afterwards.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Render

func Render(m Migration, opts Options) (map[string]string, error)

Render produces the files for a migration, splitting where required.

Example

Render turns a migration into the files a runner expects. sqlb does not apply migrations and does not track which have run: your existing runner does that, and replacing a working one is a far larger ask than adopting a generator.

package main

import (
	"fmt"

	"github.com/jryannel/sqlb/migrate"
)

func main() {
	m := migrate.Migration{
		Version: "20260727120000",
		Name:    "add_view_count",
		Changes: []migrate.Change{{
			Comment: "posts.view_count",
			Up:      `ALTER TABLE "posts" ADD COLUMN "view_count" bigint NOT NULL DEFAULT 0;`,
			Down:    `ALTER TABLE "posts" DROP COLUMN "view_count";`,
		}},
	}

	files, err := migrate.Render(m, migrate.Options{Format: migrate.Goose})
	if err != nil {
		panic(err)
	}
	for name, body := range files {
		fmt.Println("--", name)
		fmt.Println(body)
	}
}
Output:
-- 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";

func SequentialVersion

func SequentialVersion(n int) string

SequentialVersion renders the zero-padded sequential format, used with goose's -s flag.

func TimestampVersion

func TimestampVersion(t time.Time) string

TimestampVersion renders goose's default version format.

func Write

func Write(dir string, m Migration, opts Options) ([]string, error)

Write renders a migration into dir.

Types

type Change

type Change struct {
	// Up is the forward SQL. Required.
	Up string
	// Down reverses it. 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.
	Down string

	// Comment explains what the change is for, rendered above the SQL.
	Comment string

	// Destructive marks a change that can lose data: dropping a column or
	// table, narrowing a type, adding NOT NULL without a default. Destructive
	// changes render commented out unless explicitly allowed.
	Destructive bool
	// Reason explains the danger, and is required when Destructive is set.
	Reason string

	// DependsOn names what this change cannot run without, when that is another
	// change in the same migration which is itself commented out. It renders
	// commented out too, and live again as soon as the change it waits on does.
	//
	// A destructive change is emitted commented out so that applying it is a
	// deliberate act. Anything depending on it — a constraint or an index over
	// a column an ADD COLUMN introduces — has to travel with it, or the file is
	// not a reviewable no-op but a migration that fails partway through: the
	// constraint names a column the commented-out statement never added.
	//
	// It is separate from Destructive because it means something different.
	// This change loses nothing and is not dangerous; it is merely waiting on a
	// decision nobody has taken yet, and the note it renders says that rather
	// than calling it destructive. It is separate from Lock for a plainer
	// reason: that is about a statement being slow, this is about it failing.
	DependsOn string

	// Stage says which file the change belongs in, for the changes that
	// cannot share one with the changes around them.
	Stage Stage

	// Lock names the lock the statement takes when holding it costs
	// something — when the time it is held grows with the number of rows in
	// the table rather than being a catalog write. Most changes leave it "".
	//
	// The generator cannot know whether this matters: a full scan of a
	// thousand rows is free and a full scan of a billion is an outage, and
	// nothing in a schema says which table is which. So a locking change is
	// rendered live with the lock named above it, not commented out like a
	// destructive one. Destructive is commented out because applying it is
	// irreversible; this is reversible, it is just occasionally very slow.
	// Use Migration.Blocking to gate on it where the table sizes are known.
	//
	// The lock is held until the transaction commits, not until the statement
	// finishes — so everything else in the same file waits behind it.
	Lock string
	// Hazard explains what the lock costs and what to do on a table too large
	// to hold it, and is required when Lock is set.
	Hazard string
	// contains filtered or unexported fields
}

Change is one schema alteration, with the SQL to apply and reverse it.

func Diff

func Diff(current, target *schema.Registry, opts ...Option) ([]Change, error)

Diff computes the changes that take current to target.

It is a pure function over two registries rather than a comparison between a registry and a live database, which is the point: introspection produces the same *schema.Registry the DSL produces, so the same machinery generates a migration forwards and an import backwards, and the whole engine is testable without a database (ADR-0014).

One case where the two registries are not comparable as they stand

Constraints are compared by their definition text, and for a CHECK that text is not the same on both sides. Postgres stores a check as a parse tree and hands back a canonical spelling — fully parenthesised, with explicit casts on literals — so `status <> 'done'` comes back from introspect as `(status <> 'done'::text)`. Diffing a declared registry against an introspected one therefore proposes dropping and re-adding every check they have in common, forever (issue #24).

The same is true of a partial index's WHERE, which Postgres stores the same way — a declared `latitude IS NOT NULL` comes back as `(latitude IS NOT NULL)`, and the diff proposes creating an index that is already there with DDL that reads identically (issue #63).

Call shadow.Normalize on the declared registry first, which puts both through the same normalisation by asking a Postgres. That is a separate call rather than something Diff does, because doing it here would mean taking a database — and being a pure function over two registries is what the paragraph above is about. `sqlb migrate` makes the call; anything diffing against introspect.Registry output should too.

A caller that skips it gets a diff whose statements look identical to what the database already has, which is the part of both reports that cost the most to work out. So a rebuilt index and a replaced CHECK whose expression differs only in formatting say so in their Comment — an explanation, never a decision: see onlyThePredicateFormattingDiffers for why that distinction is the whole of its safety.

Enums are unaffected: an enum is text plus a CHECK (ADR-0017), and introspect reads the values back out of the normalised form rather than comparing it.

What Destructive means here

A change is marked Destructive when applying it can lose data that cannot be recovered by reversing it: dropping a table or column, or a type change that is not a widening. Adding NOT NULL to an existing column is included, because the fix for a failure is a backfill rather than a retry. Changes that merely fail loudly — adding a CHECK that existing rows violate, removing a value from an enum — are not destructive, since nothing is lost; they carry a Comment saying what to check instead.

What Lock means here

Most DDL is a catalog write that nobody notices. A few statements hold their lock for a time proportional to the number of rows — because they rewrite the table, scan it, or build an index over it — and those are the ones that turn a routine migration into an outage. A change that does one carries Lock and Hazard, naming the lock and the sequence to use instead on a table too large to hold it.

It is a note rather than a refusal, and unlike a destructive change it is not commented out. Whether a full scan matters depends on how many rows the table holds, which is not in the schema and never will be. Commenting out every SET NOT NULL would make the generator useless for the ordinary case and train people to uncomment without reading — which is how the destructive guard would stop working too. Migration.Blocking is the hook for a project that does know which of its tables are big.

For the changes whose remedy is a fixed rewrite — adding a CHECK, a FOREIGN KEY or a UNIQUE, requiring a column — Unblock performs it, moving the scan or the index build out from under the lock. It is called rather than applied automatically, for the same reason the hazard is a note: the sequence is longer, splits the migration across files, and buys nothing on a table small enough that the scan is instant. What is left after Unblock is the type change, which has no mechanical alternative at all.

What waits for what

A commented-out change is a statement that will not run, so anything needing what it would have created cannot run either. Adding a column NOT NULL with no default is destructive and comes out commented out; the UNIQUE, the CHECK and the index over that column would then fail with "column does not exist", turning a file that was meant to be a reviewable no-op into a migration that dies halfway. Those changes carry DependsOn and are commented out with it, so that one decision uncomments the whole set. See markDependents.

What is not inferred

A rename is indistinguishable from a drop and an add when only the before and after states are known, so it has to be declared: schema.RenamedFrom says a column or a table used to be called something else, and the diff emits ALTER TABLE … RENAME. Without the hint a rename is a drop and an add, which is correct, lossy, and never silently wrong. Inferring one from a similar name and type is the tempting alternative and is rejected on consequence asymmetry: a wrong inference destroys a column of production data, a missing one costs a hint (ADR-0014).

Ordering

Changes are ordered so that each one's dependencies already exist and nothing is dropped out from under something that still refers to it:

  1. CREATE EXTENSION, before anything that declares a column of its type
  2. CREATE TABLE for new tables
  3. DROP INDEX for removed and changed indexes — before the columns they cover can disappear
  4. DROP CONSTRAINT, foreign keys first, since a foreign key depends on the unique or primary key constraint it points at
  5. RENAME, of tables, columns, constraints and indexes
  6. ADD COLUMN and ALTER COLUMN
  7. DROP COLUMN
  8. ADD CONSTRAINT, foreign keys last, once every table and column exists
  9. CREATE INDEX
  10. DROP TABLE

Rendering reverses this list for the Down section, which is exactly the mirror of it, so reversibility falls out of the ordering rather than being arranged separately.

The renames sit where they do because that is the only place both sides work out. Everything before them is expressed in the old names and everything after in the new ones — and because the Down runs the list backwards, each half is reversed while the names it was written against are the ones in effect. Putting the renames first instead would leave every drop's Down re-adding a constraint against a column that no longer answers to that name.

Example

Diff compares the schema a database currently has against the one the code declares, and returns the changes between them. It is a pure function over two registries: nothing is read from a database and nothing is applied.

package main

import (
	"fmt"

	"github.com/jryannel/sqlb/migrate"
	"github.com/jryannel/sqlb/schema"
)

func main() {
	current := schema.NewRegistry()
	current.Table("posts",
		schema.UUIDv7("id").PrimaryKey(),
		schema.Text("title"),
	)

	// The same table after someone added a column to the schema file.
	target := schema.NewRegistry()
	target.Table("posts",
		schema.UUIDv7("id").PrimaryKey(),
		schema.Text("title"),
		schema.BigInt("view_count").Default(schema.Value(0)),
	)

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

A change that can lose data is marked Destructive and carries the reason. Rendering emits it commented out, so applying it is a deliberate act rather than something a generated file does on your behalf.

package main

import (
	"fmt"

	"github.com/jryannel/sqlb/migrate"
	"github.com/jryannel/sqlb/schema"
)

func main() {
	current := schema.NewRegistry()
	current.Table("posts",
		schema.UUIDv7("id").PrimaryKey(),
		schema.Text("title"),
		schema.Text("legacy_slug"),
	)

	target := schema.NewRegistry()
	target.Table("posts",
		schema.UUIDv7("id").PrimaryKey(),
		schema.Text("title"),
	)

	changes, err := migrate.Diff(current, target)
	if err != nil {
		panic(err)
	}
	for _, c := range changes {
		fmt.Println(c.Up)
		fmt.Println("destructive:", c.Destructive)
		fmt.Println("reason:", c.Reason)
	}
}
Output:
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
Example (Rename)

A rename is declared, never inferred: a drop plus an add is indistinguishable from a rename when only the before and after states are known, and guessing wrong destroys data. RenamedFrom is the declaration, and it is needed for exactly one release.

package main

import (
	"fmt"

	"github.com/jryannel/sqlb/migrate"
	"github.com/jryannel/sqlb/schema"
)

func main() {
	current := schema.NewRegistry()
	current.Table("authors",
		schema.UUIDv7("id").PrimaryKey(),
		schema.Text("email"),
	)

	target := schema.NewRegistry()
	target.Table("authors",
		schema.UUIDv7("id").PrimaryKey(),
		schema.Text("email_address").RenamedFrom("email"),
	)

	changes, err := migrate.Diff(current, target)
	if err != nil {
		panic(err)
	}
	for _, c := range changes {
		fmt.Println(c.Up)
	}
}
Output:
ALTER TABLE "authors" RENAME COLUMN "email" TO "email_address";

func Unblock

func Unblock(changes []Change) []Change

Unblock replaces the changes that hold a long lock with the sequences that do not, where such a sequence exists. There are three:

  • An ADD CONSTRAINT that would scan the table — a CHECK or a FOREIGN KEY — becomes an ADD ... NOT VALID and a VALIDATE CONSTRAINT in a later migration, moving the scan under a lock writers pass through.
  • A SET NOT NULL becomes the same pair with the requirement set between them, since Postgres accepts a validated check as proof and skips its own scan.
  • A UNIQUE or PRIMARY KEY, which has no NOT VALID form because there is no way to build an index without reading every row, becomes a CREATE UNIQUE INDEX CONCURRENTLY and an ADD CONSTRAINT ... USING INDEX that adopts it.

A type change is left alone. Rewriting a table has no in-place form at all: the alternative is a second column, a batched backfill and a cutover, and only the person doing it knows what a batch costs or when the cutover can happen.

It is a deliberate act rather than the default, for two reasons. The sequence is longer, splits the migration across files, and buys nothing on a table small enough that the scan is instant — which most tables are. And none of them is equivalent under failure. A plain statement that meets a bad row leaves nothing behind; these leave a constraint in place unvalidated and binding, or an invalid index that has to be dropped before the migration can be retried. That is usually the right trade on a large table and it is still a different outcome, so it is chosen rather than assumed.

The end state on success is identical, which is what makes the substitution safe: the temporary check a SET NOT NULL needs is dropped by the same sequence that created it, and the index a unique constraint adopts is built under the name the constraint will take.

The usual shape is to look before deciding:

changes, err := migrate.Diff(current, target)
if len(migrate.Migration{Changes: changes}.Blocking()) > 0 {
	changes = migrate.Unblock(changes)
}

Changes with no alternative are passed through untouched and still report themselves through Blocking.

type Format

type Format interface {
	// Name identifies the format in diagnostics and configuration.
	Name() string
	// Render returns filename → contents.
	Render(m Migration, opts Options) (map[string]string, error)
}

Format renders a migration to one or more files.

var GolangMigrate Format = golangMigrateFormat{}

GolangMigrate renders golang-migrate migrations: separate .up.sql and .down.sql files per version.

var Goose Format = gooseFormat{}

Goose renders pressly/goose migrations: one file per migration, with Up and Down separated by annotations.

var Plain Format = plainFormat{}

Plain renders bare SQL with no runner-specific annotations, for projects applying migrations by hand or with a tool that needs neither.

func ByName

func ByName(name string) (Format, error)

ByName resolves a format from configuration.

type Migration

type Migration struct {
	Version string
	Name    string
	Changes []Change
}

Migration is an ordered set of changes released together.

func Split

func Split(m Migration) []Migration

Split separates changes that cannot share a file.

Transaction control in both goose and golang-migrate is per file, not per statement, so a change needing a transaction of its own needs a file of its own. A migration containing CREATE INDEX CONCURRENTLY must disable transactions for everything in it — which would silently remove the rollback guarantee from every other change generated at the same time — and a VALIDATE CONSTRAINT must land after the transaction holding the ADD CONSTRAINT it validates has committed. Splitting keeps the ordinary changes transactional and gives each of the others what it needs.

Files come out in stage order, so the tables, columns and indexes each one depends on exist by the time it runs.

func (Migration) Blocking

func (m Migration) Blocking() []Change

Blocking returns the changes that hold a lock for a time proportional to the size of the table, in the order they are applied.

It is the hook for a policy this package cannot have: whether a full scan is acceptable depends on how many rows the table holds, which is not in the schema. A project that knows its big tables can refuse a migration touching one, or route it to whoever sequences an expand/contract rollout.

func (Migration) Destructive

func (m Migration) Destructive() bool

Destructive reports whether any change can lose data.

type Option

type Option func(*diffOptions)

Option configures Diff.

func MinPostgres

func MinPostgres(major int) Option

MinPostgres declares the oldest Postgres major version the generated migration has to run on, which lets the DDL layer use a built-in where one exists instead of requiring an extension.

Today it changes exactly one thing. schema.GenUUIDv7 emits uuid_generate_v7(), which is the pg_uuidv7 extension's spelling — so a migration for a UUIDv7 primary key does not apply to a stock Postgres at all. Postgres 18 has uuidv7() built in, and MinPostgres(18) emits that instead.

Unset means the old spelling, which is the behaviour every migration generated before this option existed already has. A default that silently changed emitted DDL would be the one mistake ADR-0014 says is not recoverable by regenerating.

Pass it consistently across a project. Generating one migration with it and the next without leaves a table whose columns default through two different spellings of the same generator — harmless to the database, confusing to read, and a diff will not flag it because both import back to the same schema.GenUUIDv7.

type Options

type Options struct {
	// Format defaults to Goose.
	Format Format
	// AllowDestructive emits destructive SQL live instead of commented out.
	// It exists so that dropping a column is a deliberate act with a flag
	// attached, not something that happens because a generator decided it.
	AllowDestructive bool
}

Options control rendering.

type Stage

type Stage int

Stage says which file a change belongs in.

Transaction control is per file in every runner this package targets, so "a different transaction" and "a different file" are the same thing here — which is why this is a property of a change rather than of a statement.

const (
	// StageMain is an ordinary change, applied in the migration's own
	// transaction along with everything else. Almost everything is this.
	StageMain Stage = iota

	// StageValidate is the second half of a NOT VALID sequence: the statement
	// that scans the table to prove a constraint holds. It has to run in a
	// later transaction than the ADD CONSTRAINT it validates, because the brief
	// ACCESS EXCLUSIVE that the add takes is held until its transaction
	// commits — validating inside that transaction would hold the strong lock
	// for the length of the scan, which is the thing the sequence exists to
	// avoid.
	StageValidate

	// StageFinish is the cheap remainder of a sequence whose scanning is done:
	// the SET NOT NULL that a validated check has made instant, and the drop of
	// that check afterwards.
	//
	// It shares a file with StageValidate and must come after everything in it.
	// Both of these take ACCESS EXCLUSIVE, and a lock is held until the
	// transaction commits rather than until the statement ends — so a
	// validation scheduled after one of them would do its scan underneath it,
	// which is exactly what the sequence exists to prevent. They are cheap, so
	// running last costs nothing.
	StageFinish

	// StageAdopt is the catalog write that takes over what a concurrent index
	// build produced: ADD CONSTRAINT ... USING INDEX. It has to follow the
	// build, and it needs a transaction, so it cannot be in the file that has
	// none. Like StageFinish it takes ACCESS EXCLUSIVE and so goes after every
	// scan sharing its transaction.
	StageAdopt

	// StageConcurrent cannot run inside a transaction at all: CREATE INDEX
	// CONCURRENTLY and DROP INDEX CONCURRENTLY. Building an index without
	// CONCURRENTLY takes a lock that blocks writes for the duration, so on a
	// live table this is not optional.
	StageConcurrent
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL