Skip to content
gohacks.dev
Databases

Atlas and Declarative Schema Migrations for Go Projects

How Atlas manages a schema from Go projects: declarative apply versus versioned migrations, the dev database, atlas.sum and lint, and when each approach fits.

10 min read
Summarize in AI
Flat illustration of a database beside the table it holds.

Most Go migration tools are runners. You write the SQL, number it, and the tool applies the files in order, as goose and golang-migrate do. Atlas starts from a different question: given the schema you want and the schema you have, what is the change between them? It computes that change, and you decide whether to apply it straight away or write it down as a migration first. For a Go project whose models or schema.sql already describe the desired state, that removes the most error prone step of migration work, which is writing the diff by hand.

Tested with Not run. The Atlas CLI was not installed in the lab, so commands and files here are quoted from the Atlas documentation and checked for consistency, not executed. Go 1.25.6 and PostgreSQL 18.6 were used for the surrounding examples.

This is a guide to the model and to the decisions around it. It was not run. The Atlas command line tool was not available in the lab that produced the other articles in this cluster, and an article about tooling that quotes output nobody produced is the failure the editorial rules exist to prevent. Everything below is drawn from Atlas's documentation, kept to commands and file formats that are stable across recent releases, and marked where you should check your installed version. The surrounding material (embedding, locks, what is safe on a live table) was measured elsewhere and is linked.

It belongs to the database cornerstone.

Two ways to use it

Atlas supports two workflows, and most confusion about the tool comes from not choosing one.

DeclarativeVersioned
You writeThe desired schema (HCL, SQL or a Go model)The desired schema; Atlas writes the migration files
Commandatlas schema applyatlas migrate diff, then atlas migrate apply
StateThe live database is compared at run timeA directory of numbered SQL files plus atlas.sum
ReviewA plan printed before it runsA file in the pull request
Typical useLocal development, ephemeral and test databasesStaging and production

Declarative is how infrastructure as code works for servers. You describe the end state, and the tool plans the steps. atlas schema apply --url "postgres://..." --to "file://schema.sql" --dev-url "docker://postgres/18/dev" prints the statements it will run and asks for confirmation. There is no history to maintain, which is why it is pleasant for a development database you rebuild every morning. It is also why it is uncomfortable for production: the plan is computed against whatever the database looks like when you run it, so two environments can receive different statements from the same input.

Versioned takes the same inputs and freezes the result. atlas migrate diff add_orders --env local compares the desired schema with the schema the existing migration files produce and writes the missing step as migrations/20261027120000_add_orders.sql. That file is an ordinary SQL file. It is reviewed in a pull request, applied identically in every environment with atlas migrate apply, and it is the record of what happened. This is the mode to use anywhere a person must sign off on the SQL.

The two modes share the hard part, the diff engine, and the decision is only about when you let it run.

The dev database

migrate diff and schema apply both take a --dev-url, and the idea is worth understanding because it is what makes the diffs trustworthy. Atlas needs to know what schema your desired state produces, and the only reliable way to know that is to run it on a real database engine of the right version. The dev database is a disposable, empty database where Atlas replays your files or loads your desired schema, inspects the result, and compares. It is also used to normalise the SQL: two spellings of the same column type compare equal after a round trip through the server.

The documentation's usual form is docker://postgres/18/dev, which starts a throwaway container, uses it and removes it. A local scratch server works too, as long as it is empty or its schema is expendable. Three consequences follow:

  • Match the engine version to production. A diff computed on PostgreSQL 15 can contain SQL that differs on 18.
  • Never point the dev URL at a database that matters. Atlas will create and drop objects in it.
  • CI needs Docker or a service container. The same scratch server is what makes migrate lint and the checks below possible, so pipelines usually run it as a service.

A project layout

A common Go layout keeps the desired schema and the history side by side:

Text
atlas.hcl
schema.sql
migrations/
  20261027120000_create_orders.sql
  20261027121500_add_status.sql
  atlas.sum

The configuration file names an environment, which bundles the source of the desired schema, the migration directory and the dev database:

hcl
env "local" {
  src = "file://schema.sql"
  dev = "docker://postgres/18/dev"
  migration {
    dir = "file://migrations"
  }
}

With that in place the daily loop is short:

Shell
atlas migrate diff add_status --env local
atlas migrate apply --env local --url "$DATABASE_URL"

The first command reads schema.sql, replays migrations/ on the dev database, computes the difference and writes a new file. The second applies pending files and records them in a revisions table (atlas_schema_revisions), the same idea as goose's version table. Check flags and names against atlas migrate diff --help for your version; the project has renamed a few over time.

atlas.sum and why it exists

The directory contains a file named atlas.sum. It holds a hash of every migration file and a hash of the whole list. If anyone edits an applied file, deletes one or reorders them, the hashes no longer match and Atlas refuses to continue until the sum is regenerated, which the person doing it has to do on purpose (atlas migrate hash).

This solves a problem the plain runners leave open. goose and golang-migrate store a version, not a checksum, so a file edited after it was applied produces no error; one environment quietly has the old statement and another the new. The same file also guards against a merge conflict in a team: two branches that each add a migration both change atlas.sum, so the second merge conflicts in git and forces someone to rebase and regenerate rather than letting two files with clashing history land. Treat atlas.sum as part of the migration, always commit it with the files, and never resolve its conflicts by hand. Regenerate it.

Go models as the source

If your schema lives in Go code, Atlas can read it from there. It loads the desired schema through a provider program that turns the models into SQL, and the result feeds the same diff. For GORM, Atlas documents a provider module (ariga.io/atlas-provider-gorm); for ent, the generator already knows how to emit a schema. The configuration declares the Go side as an external schema:

hcl
data "external_schema" "gorm" {
  program = [
    "go", "run", "-mod=mod",
    "ariga.io/atlas-provider-gorm", "load",
    "--path", "./internal/models",
    "--dialect", "postgres",
  ]
}

env "local" {
  src = data.external_schema.gorm.url
  dev = "docker://postgres/18/dev"
  migration { dir = "file://migrations" }
}

The advantage is one source of truth: change a struct field and migrate diff writes the statement. The caution is the usual one for generated migrations, covered below. If you do not use an ORM, a hand written schema.sql is a perfectly good source, and it is also readable by sqlc, which can take the same file as its schema.

What a diff cannot know

A diff engine sees two schemas. It does not see your data or your traffic. That limits what you should let it decide for you.

  • Renames look like a drop and an add. Renaming qty to quantity in the desired schema produces drop column qty and add column quantity, which loses the data. Atlas can be told about renames through its migration directives, and you should read every diff for this pattern before it merges.
  • It does not choose a safe strategy. A type change from int to bigint is correct SQL and a table rewrite under an exclusive lock. The strategies in zero downtime migrations, such as create index concurrently and not valid constraints, are decisions about the live database. Atlas ships analysers that flag several of these (concurrent index creation, data dependent changes, destructive changes) in atlas migrate lint; whether a given analyser runs depends on your edition and version, so read its current documentation.
  • It does not backfill. Adding a not null column with no default to a populated table fails on apply. The migration file needs a hand written step, and a versioned file is the place to put it, since you can edit the generated SQL before committing.

The working rule: generated files are drafts. Read them, edit them, and let atlas.sum pin the result.

Reading a generated file

The skill that matters most with a diff tool is reading its output critically. Suppose the desired schema gains a status column on orders with a default, and loses an unused legacy_ref column. A generated file will look like this, shown here as an illustration of the shape, not as recorded output:

SQL
-- Add column "status" to table: "orders"
ALTER TABLE "orders" ADD COLUMN "status" text NOT NULL DEFAULT 'new';
-- Drop column "legacy_ref" from table: "orders"
ALTER TABLE "orders" DROP COLUMN "legacy_ref";

Three questions turn that file from a draft into a migration. Is add column ... default 'new' a metadata only change on your server version? On PostgreSQL 11 and later it is. Does any running code still read legacy_ref? If so, the drop belongs in a later release, after the code stops using it, and the file should be split. And is there an index or constraint the new column needs, built in a way that does not block writes? Those answers are not in the diff. They come from knowing the service, and they are why the generated file is committed to review and not applied from a laptop.

Keep migration files small, one concern each. A file that adds a column, backfills it, adds a constraint and drops another column cannot be reasoned about, cannot be retried halfway and cannot be rolled forward past a failure. migrate diff generates whatever difference exists between two states, so it is the author's job to run it often enough that each file stays a single idea.

Linting and checks in CI

Versioned mode earns its keep in a pipeline. A reasonable pull request check does four things:

  1. Replays the whole migrations/ directory on an empty dev database, which proves the files apply cleanly from nothing.
  2. Verifies atlas.sum is intact, which proves nobody edited history.
  3. Runs atlas migrate lint against the changes in the branch, which flags destructive or lock heavy statements.
  4. Checks that the desired schema and the migration history agree, with a migrate diff that is expected to produce no new file.

The fourth catches the most common slip, changing the model without writing the migration. For the first, testing migrations in CI goes through the Go side with real containers. Whether lint and the schema comparison are available without an account depends on the Atlas release you install, and the project's licensing has moved between versions, so confirm in its documentation before you design a pipeline around them.

Choosing between Atlas and a plain runner

Pick Atlas whenPick a runner (goose, golang-migrate) when
Models or a schema file already exist and you want diffs written for youYou prefer writing every statement yourself
Checksums on history matter to the teamA version number and an ordered folder are enough
You want several environments planned from one sourceYou need a few migrations that are mostly data changes and Go code
Your pipeline can run a dev databaseYour pipeline cannot, or should not

Two honest costs of Atlas are the dev database requirement and a larger surface to learn: environments, the HCL file, directives and the lint rules are more to understand than a folder of SQL and a version row. Two honest costs of the runners are that every diff is hand written, and that nothing stops an applied file being edited. Neither tool makes a migration safe on a busy table. That is the author's job in both.

Adopting it on an existing database

If a production database already has a history under another tool, you do not have to start over. Atlas can inspect the live schema into a file (atlas schema inspect --url ... --format '{{ sql . }}'), which becomes the first desired state and the baseline. You then mark the existing state as applied with the baseline option of migrate apply so Atlas does not try to create tables that exist. The sequence is: inspect, commit the baseline as migration zero, point new work at the versioned directory, and retire the old tool's folder once every environment has crossed over. Do the first run against a restored backup, not production, and compare pg_dump --schema-only before and after.

The Atlas documentation covers versioned migrations and declarative workflows with the current flags, and the project source is on GitHub. If you embed migration files in a service binary and apply them at start, the patterns in embedding migrations apply to Atlas's directory format as well, with the locking caveats described there.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.