Database migrations
How schema changes are written, applied and ordered — and the two rules that keep rollbacks possible.
Migrations live in one file: backend/internal/repository/postgres/migrate.go,
as a slice of {version, sql} pairs.
Around 257 migrations at the time of writing.
How they run
On start, the backend:
- Creates
schema_migrationsif it does not exist. - Sorts the slice by version — ascending, regardless of where the entry sits in the file.
- Skips any version already recorded.
- Applies the rest, recording each.
The sort is why a migration added in the middle of the file still runs last. Position in the source does not decide order; the version prefix does. Version prefixes are zero-padded so a lexicographic sort equals the intended numeric order.
An advisory lock is taken around the whole run, so two replicas starting together do not race.
Adding one
{"258_thing_that_changed", `
-- ── Why this table exists ──────────────────────────────────────────
--
-- The reason, in prose, for whoever reads this in a year.
CREATE TABLE IF NOT EXISTS thing (
org_id UUID NOT NULL,
...
PRIMARY KEY (org_id, ...)
);
CREATE INDEX IF NOT EXISTS idx_thing_q ON thing(org_id, bucket);
`},The SQL is a Go raw string literal. A backtick anywhere inside it ends the literal and breaks the build in a way whose error message points somewhere else entirely. Never put a backtick in migration SQL.
The two rules
1. Backwards-compatible for one release
During a rolling deploy both versions are serving. The old code must work against the new schema for the length of the rollout.
- Adding a column: fine. Give it a default.
- Adding a table or index: fine.
- Dropping or renaming a column: two releases. Add the new one and start writing both; migrate; remove the old one next release.
This is also what makes a rollback within a release always safe.
2. Idempotent
IF NOT EXISTS everywhere. A migration that has partially applied — the process
died halfway — must be safe to run again.
JSONB instead of a column
Several features store structured settings as JSONB. Adding a field to such a structure needs no migration: the Go struct is marshalled whole.
This is why, for example, clock alignment was added to SQL alert schedules with
no schema change — schedule was already JSONB.
Use this deliberately, not as a habit. A field you will want to query or index belongs in a column.
Checking what has run
SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 10;Retention
Migrations create tables; the cleanup worker prunes them. A new high-volume table needs an entry in the cleanup worker's retention list, or it grows forever. See Data retention.
Where this behaviour lives: backend/internal/repository/postgres/migrate.go. If the code and this page disagree, the code is right — please fix the page.
Part of Running AccelerUp — Operating the platform itself: architecture, deploys, backups.