# golang-migrate and Atlas

> Database migrations for Go and beyond: golang-migrate's plain up/down SQL files versus Atlas's declarative, diff-the-desired-state approach.


---

# golang-migrate and Atlas

You're shipping a Go service, the schema keeps changing, and you've hit the question every backend dev hits: how do these schema changes travel from your laptop to production in the same order, every time, without someone running SQL by hand at 2am? Two tools dominate this corner of the Go world, and they answer that question from opposite ends. golang-migrate hands you raw up/down SQL files. Atlas asks you to describe the schema you *want* and figures out the SQL for you. Neither is wrong; they fit different teams and different days.

This guide builds a working mental model of both, then walks the everyday commands, then drops you into the production reality where the differences actually start to matter.

## How to read this

Read it in order the first time. Phase 1 is the mental model that makes everything after it obvious: imperative migrations (you write each step) versus declarative migrations (you write the destination). Phase 2 is the muscle memory - creating, applying, and rolling back changes with each tool, command by command. Phase 3 is the part that costs people weekends: drift, half-applied migrations, dirty state, and how each tool keeps production accurate.

If you already live in one tool and are sizing up the other, skim Phase 1 for the framing, then read Phase 2's two halves side by side.

## The phases

1. [Phase 1: Two philosophies of change](01-two-philosophies.md) - what each tool actually is, and why "write the steps" and "write the destination" are different answers to the same problem.
2. [Phase 2: The everyday loop](02-the-everyday-loop.md) - create, apply, and roll back migrations with golang-migrate and Atlas, command by command.
3. [Phase 3: Production reality](03-production-reality.md) - dirty state, drift, partial failures, and the gotchas that decide which tool you'll trust.


---

# Two philosophies of change

Here's the situation you're actually in. Your database has a shape - tables, columns, indexes, constraints. That shape needs to change as the product grows. And the change has to be *reproducible*: the same change, in the same order, on your laptop, in CI, on staging, and finally in production. If two environments drift apart, you get the bug that only happens in prod, and you lose an afternoon proving it's a schema problem and not your code.

Every migration tool exists to make schema change ordered and repeatable. The interesting question is *what you write down*. There are two answers, and golang-migrate and Atlas each pick one.

## Imperative: write the steps

golang-migrate is **imperative**. You write each change as a pair of SQL files - one to apply the change (`up`), one to undo it (`down`). The tool keeps a counter of which migrations have run, and applying "up" means running every file you haven't run yet, in numeric order.

```text
migrations/
  000001_create_users.up.sql
  000001_create_users.down.sql
  000002_add_email_index.up.sql
  000002_add_email_index.down.sql
```

The `up` file says what to do; the `down` file says how to take it back:

```sql
-- 000002_add_email_index.up.sql
CREATE INDEX idx_users_email ON users (email);

-- 000002_add_email_index.down.sql
DROP INDEX idx_users_email;
```

*What just happened:* you described the *transition* - the exact SQL to move from one schema version to the next, and the exact SQL to reverse it. golang-migrate never inspects your database to figure out what to run; it trusts the version counter and runs the files in order. You own the correctness of every line.

The mental model: golang-migrate is a **ledger of edits**. Each migration is one entry. History is the sum of all edits applied so far. You think in *changes*.

## Declarative: write the destination

Atlas can also do versioned migrations (more on that in Phase 2), but its signature mode is **declarative**. You don't write the steps - you write the schema you *want*, and Atlas computes the steps to get there by comparing the desired schema against the current database.

You describe the destination in a schema file. Atlas supports its own HCL format and also plain SQL:

```sql
-- schema.sql - the desired state, not a migration
CREATE TABLE users (
  id    BIGINT PRIMARY KEY,
  email TEXT NOT NULL
);
CREATE INDEX idx_users_email ON users (email);
```

Then you ask Atlas to make reality match that file:

```console
$ atlas schema apply \
    --url "postgres://localhost:5432/app?sslmode=disable" \
    --to "file://schema.sql" \
    --dev-url "docker://postgres/16/dev"
-- Planned Changes:
CREATE INDEX idx_users_email ON users (email);
Apply changes? [y/N]
```

*What just happened:* Atlas inspected the live database, inspected your `schema.sql`, found the only difference (the index didn't exist yet), and generated exactly the SQL to close the gap. You never wrote `CREATE INDEX`. You said "the index should exist" and Atlas worked out the rest. The `--dev-url` points at a throwaway database Atlas spins up to safely normalize and plan the diff - it never plans against production blindly.

The mental model: Atlas declarative is a **thermostat**. You set the target; it figures out whether to heat or cool. You think in *desired state*, not in changes.

## Why the difference matters

This isn't a cosmetic distinction - it changes who is responsible for what.

```text
              IMPERATIVE                 DECLARATIVE
              (golang-migrate)           (Atlas declarative)

you write     each up/down step          the final schema
tool does     run files in order         diff current vs desired
truth lives   in the migration files     in the schema file
git diff      shows the transition       shows the destination
```

With imperative migrations, your git history reads like a changelog: "added this index", "dropped that column". You can see exactly what ran and when. The cost is that *you* write the SQL for every step, including the tricky reversals, and nothing stops two engineers from writing migrations that conflict.

With declarative migrations, your git history reads like a spec: here is what the schema should look like *now*. Reviewing a pull request means reading the desired state, not reconstructing it from a pile of deltas. The cost is that the diff is computed, so you have to *trust and review the plan* the tool generates before it touches anything - a generated plan can choose a destructive path (drop-and-recreate) when you expected a gentle one.

> Neither philosophy is "the modern one." Imperative gives you total control and a literal audit trail of edits. Declarative gives you a readable source of truth and less hand-written SQL. The right call depends on how much you trust generated plans and how much you value the changelog-style history.

## For builders

A useful way to predict which you'll reach for: **how often does your schema change in ways that are hard to express as a clean step?** If most changes are "add a column, add an index, add a table," imperative files stay short and obvious. If you maintain a large schema where you'd rather reason about the whole shape than a hundred accumulated deltas - and you have a review culture that will actually read generated plans - declarative starts paying off. Atlas also lets you run declarative *during development* to generate versioned files you commit, which is a common middle path. You'll see that in Phase 2.

For the bigger picture of why ordered, repeatable schema change matters at all, see [/guides/database-migrations](/guides/database-migrations). If Go itself is still new to you, [/guides/go-from-zero](/guides/go-from-zero) gets you to the point where golang-migrate's library mode makes sense.

```quiz
[
  {
    "q": "In golang-migrate, what does a `.down.sql` file contain?",
    "choices": [
      "The desired final state of that table",
      "The SQL to reverse the change made by its matching `.up.sql`",
      "A backup of the data before the migration",
      "The diff between the current and target schema"
    ],
    "answer": 1,
    "explain": "golang-migrate is imperative: each migration is a pair of steps, `up` to apply and `down` to reverse. You write both."
  },
  {
    "q": "What does Atlas's declarative mode do that golang-migrate does not?",
    "choices": [
      "It runs SQL files in numeric order",
      "It inspects the live database and computes the SQL needed to reach a desired schema",
      "It stores migrations as timestamped files only",
      "It refuses to apply any change without a down file"
    ],
    "answer": 1,
    "explain": "Declarative Atlas diffs current state against the desired schema you describe, then generates the steps. You write the destination, not the transition."
  },
  {
    "q": "Which statement best captures the core tradeoff between the two philosophies?",
    "choices": [
      "Imperative is faster; declarative is slower",
      "Imperative gives a literal edit history but you write every step; declarative gives a readable source of truth but you must trust the generated plan",
      "Declarative cannot be used in production",
      "Imperative only works with PostgreSQL"
    ],
    "answer": 1,
    "explain": "Imperative = changelog of steps you author. Declarative = a spec the tool diffs into steps you review. That's the real difference."
  }
]
```


---

# The everyday loop

The mental model is set. Now the muscle memory. The daily rhythm with both tools is the same shape - create a change, apply it, sometimes undo it - but the commands and the feel are different. We'll do golang-migrate first because it maps directly to the imperative model, then Atlas in both its versioned and declarative modes.

Throughout, the connection string is a URL. For golang-migrate's CLI you'll often set it once:

```bash
export DATABASE_URL="postgres://app:secret@localhost:5432/app?sslmode=disable"
```

*What just happened:* you put the database location in one place so the commands below stay short. Both tools read connection details from a URL like this; the scheme (`postgres://`, `mysql://`, `sqlite://`) tells them which driver to use.

## golang-migrate: create

A migration is a numbered pair of files. The CLI scaffolds both:

```console
$ migrate create -ext sql -dir migrations -seq add_users_table
.../migrations/000001_add_users_table.up.sql
.../migrations/000001_add_users_table.down.sql
```

*What just happened:* `-seq` gave you zero-padded sequential numbers (`000001`) instead of a timestamp - a clean, readable order. `-ext sql` set the file extension; `-dir` chose where they land. You now have two empty files to fill in.

Fill them in with the change and its reversal:

```sql
-- 000001_add_users_table.up.sql
CREATE TABLE users (
  id    BIGSERIAL PRIMARY KEY,
  email TEXT NOT NULL UNIQUE
);

-- 000001_add_users_table.down.sql
DROP TABLE users;
```

## golang-migrate: apply and roll back

```console
$ migrate -database "$DATABASE_URL" -path migrations up
1/u add_users_table (18.4ms)
```

*What just happened:* golang-migrate looked at its bookkeeping table (`schema_migrations`) in your database, saw version 0, and ran every `up` file after that in order - here, only migration 1. It then recorded the new current version as 1. Run it again and nothing happens, because there's nothing newer.

To undo, you step *down* by a count:

```console
$ migrate -database "$DATABASE_URL" -path migrations down 1
1/d add_users_table (12.1ms)
```

*What just happened:* `down 1` ran exactly one `down` file - the most recent applied migration's reversal - and rolled the recorded version back to 0. Plain `down` with no number rolls back *everything*, which is rarely what you want; always pass a count.

You can also jump to a specific version or check where you are:

```console
$ migrate -database "$DATABASE_URL" -path migrations version
1
$ migrate -database "$DATABASE_URL" -path migrations goto 1
```

*What just happened:* `version` printed the current recorded version; `goto N` migrated up or down as needed to land exactly on version N. This is the imperative model in action - everything keys off that integer counter.

## golang-migrate: from Go code

The same migrations can run from inside your service at startup, which many teams prefer over a separate CLI step. The library embeds the files and applies them:

```go
import (
    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

m, err := migrate.New("file://migrations", databaseURL)
if err != nil { log.Fatal(err) }
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
    log.Fatal(err)
}
```

*What just happened:* `m.Up()` did exactly what the CLI's `up` did, from inside your program. The two blank imports register the Postgres driver and the file source - golang-migrate's drivers are opt-in, so you import only what you use. Note the `ErrNoChange` check: when there's nothing new to apply, `Up()` returns that sentinel error, and treating it as fatal would crash every clean boot.

## Atlas: versioned mode (the familiar shape)

Atlas can work like golang-migrate - generating versioned files you commit - but with a twist: it can *author the SQL for you* by diffing your desired schema against the migration history.

You keep a desired-state file and ask Atlas for the next migration:

```console
$ atlas migrate diff add_users_table \
    --dir "file://migrations" \
    --to "file://schema.sql" \
    --dev-url "docker://postgres/16/dev"
Generated migrations/20260630090000_add_users_table.sql
```

*What just happened:* Atlas replayed your existing migrations onto a throwaway dev database (`--dev-url`), compared the result against `schema.sql`, and *wrote the SQL for the difference* into a new timestamped migration file. You get a versioned, committable file like golang-migrate, but you didn't hand-write the `CREATE TABLE` - you edited the desired schema and Atlas produced the step. Applying it is then ordinary:

```console
$ atlas migrate apply \
    --dir "file://migrations" \
    --url "$DATABASE_URL"
Migrating to version 20260630090000 (1 migration in total):
  -- migrating version 20260630090000
    -> CREATE TABLE users (...);
  -- ok (9.2ms)
```

*What just happened:* Atlas applied the pending versioned files in order and recorded them in its own tracking table (`atlas_schema_revisions`). Same destination as golang-migrate's `up`, reached through generated rather than hand-written SQL.

## Atlas: declarative mode (skip the files)

In declarative mode there are no migration files at all. You keep only the desired schema and let Atlas reconcile reality to it:

```console
$ atlas schema apply \
    --url "$DATABASE_URL" \
    --to "file://schema.sql" \
    --dev-url "docker://postgres/16/dev"
-- Planned Changes:
ALTER TABLE users ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT now();
Apply changes? [y/N] y
```

*What just happened:* you added one line to `schema.sql` (a `created_at` column), and Atlas computed the single `ALTER TABLE` needed to make the live database match. There's no migration file to write or name - the schema file *is* the source of truth. The `[y/N]` prompt is the safety gate: you read the plan, then approve. In CI you'd pass `--auto-approve`, but only after a separate review of the plan.

> Pick one mode per project and commit. Mixing hand-edited golang-migrate files with Atlas declarative apply against the same database is how you get two tools fighting over the same schema and neither one trusting its own bookkeeping.

## In the wild

A common, sane setup: **Atlas versioned mode** as the team default - you edit a desired schema, Atlas generates reviewable SQL files, and those files apply identically in CI and prod. You get the readability of declarative *authoring* with the audit trail and predictability of committed versioned files. Pure declarative `schema apply` shines for prototyping and internal tools where the schema file as single source of truth is worth more than a file-by-file history. golang-migrate stays the lean choice when you want zero magic - every line of SQL is one you wrote and can point to.

```quiz
[
  {
    "q": "In golang-migrate, what does `migrate ... down` with no number do?",
    "choices": [
      "Rolls back exactly one migration",
      "Rolls back every applied migration",
      "Does nothing without a confirmation flag",
      "Rolls back to the previous timestamp"
    ],
    "answer": 1,
    "explain": "Bare `down` reverses everything. To undo a single step you must pass a count, e.g. `down 1`."
  },
  {
    "q": "What does `atlas migrate diff` produce?",
    "choices": [
      "A live ALTER applied immediately to production",
      "A new versioned migration file containing the SQL to reach the desired schema",
      "A backup of the current database",
      "A report with no files written"
    ],
    "answer": 1,
    "explain": "`migrate diff` compares the migration history (replayed on the dev database) against the desired schema and writes a new committable migration file."
  },
  {
    "q": "Why does the golang-migrate Go example check for `migrate.ErrNoChange`?",
    "choices": [
      "Because Up() always fails the first time",
      "Because Up() returns that sentinel when there is nothing to apply, and treating it as fatal would crash a clean boot",
      "Because it confirms the database URL is valid",
      "Because it rolls back on error automatically"
    ],
    "answer": 1,
    "explain": "When no new migrations exist, Up() returns ErrNoChange. You filter it out so a fully-migrated service can start normally."
  }
]
```


---

# Production reality

Both tools are calm on your laptop. Production is where their differences turn into the thing you'll remember them by. This phase is the stuff that costs people weekends: what happens when a migration fails halfway, how each tool detects when the real schema has drifted from what it expects, and the decisions you'll regret if you skip them. Read this before you point either tool at a database you care about.

## golang-migrate: the dirty flag

golang-migrate's bookkeeping table, `schema_migrations`, holds two things: the current `version` and a `dirty` boolean. The dirty flag is the single most important thing to understand about this tool.

When golang-migrate starts a migration, it sets `dirty = true`. If the migration finishes, it clears the flag and bumps the version. If the migration *fails partway* - a bad `ALTER`, a constraint violation, a dropped connection - the flag stays `true`, and every future run refuses to proceed:

```console
$ migrate -database "$DATABASE_URL" -path migrations up
error: Dirty database version 3. Fix and force version.
```

*What just happened:* migration 3 died mid-flight. golang-migrate has no idea how much of it ran, so it stops and demands a human. It will not guess. This is a feature, not a bug - but it means *you* have to inspect the database, figure out what actually got applied, finish or revert it by hand, and then tell golang-migrate the truth:

```console
$ migrate -database "$DATABASE_URL" -path migrations force 3
```

*What just happened:* `force 3` cleared the dirty flag and set the recorded version to 3 - *without running any SQL*. You are asserting "the database is genuinely at version 3, trust me." Get this wrong and you'll skip or re-run a migration. The deeper lesson: golang-migrate does not wrap migrations in a transaction for you. If your database supports transactional DDL (PostgreSQL mostly does; MySQL mostly does not), the engine may roll back a failed statement - but the dirty flag is still your responsibility to clear.

> The dirty flag is golang-migrate keeping you accountable. It would rather halt and make you look than silently continue on a half-applied schema. Respect it: never `force` a version without first checking the real schema with your own eyes.

## Atlas: drift and the dev database

Atlas attacks the same danger from a different angle. Because declarative mode *computes* the plan from the live database, it can also *detect* when the live database doesn't match what it expects - that's drift.

```console
$ atlas migrate apply --dir "file://migrations" --url "$DATABASE_URL"
Error: migration files mismatch: checksum of 20260630090000_add_users.sql ...
```

*What just happened:* Atlas keeps a checksum file (`atlas.sum`) over your migration directory. Someone edited an already-applied migration file, the checksum no longer matches, and Atlas refused to run rather than apply a tampered history. Editing a migration that has already shipped is the cardinal sin of versioned migrations, and Atlas turns it into a hard stop. The fix is to add a *new* migration, never to mutate an old one.

The `--dev-url` you keep passing is also a production safeguard, not a formality. Atlas uses that throwaway database to *plan and validate* a migration before it touches the real one - normalizing SQL, catching invalid statements, and computing a clean diff in a place where mistakes cost nothing.

```text
   schema.sql ──┐
                ├─► [ dev database ] ──► validated plan ──► real database
 migration dir ─┘    (throwaway)          (reviewed)         (applied)
```

*What just happened:* the dev database sits between your intent and production. Atlas rehearses there first. Skip `--dev-url` and you lose that rehearsal - Atlas can still run, but with weaker guarantees about the plan it generates.

## The destructive-change trap (declarative's sharp edge)

Declarative mode's convenience hides a real risk. When you remove a table from `schema.sql`, you are telling Atlas "this should not exist" - and Atlas will plan a `DROP TABLE` to make it so. The same goes for narrowing a column type or removing a column. The tool is doing exactly what you said; the problem is that "what you said" was a deletion you may not have meant.

```console
$ atlas schema apply --url "$DATABASE_URL" --to "file://schema.sql" --dev-url "..."
-- Planned Changes:
DROP TABLE legacy_orders;     -- ← did you mean to lose this data?
Apply changes? [y/N]
```

*What just happened:* you deleted six lines from a schema file in a pull request, and Atlas turned that into a data-destroying `DROP`. This is *the* reason you never auto-approve a declarative plan without reading it. Atlas helps here: you can run `atlas migrate lint` (or schema apply with linting) to flag destructive and risky changes in CI, so a `DROP` or a `NOT NULL` added without a default gets caught before review, not after deploy.

golang-migrate has the mirror-image risk in its `down` files. A reversal that does `DROP COLUMN` will happily destroy data if you ever roll back in production. The plain stance both tools share: **down/rollback is rarely safe to run against real data.** In production, the usual practice is roll *forward* with a new corrective migration, not roll back.

## Choosing, concretely

Here's the decision stripped to its bones:

```text
Reach for golang-migrate when:
  - you want zero generated SQL; every line is one you wrote
  - the team is comfortable owning up/down by hand
  - you value a literal, change-by-change git history

Reach for Atlas when:
  - you'd rather edit one desired schema than author each delta
  - you want generated SQL plus CI linting for destructive changes
  - you'll actually review the plans the tool produces
  - (versioned mode) you want both: generated files, committed and ordered
```

*What just happened:* the choice comes down to control versus leverage, and to whether your team will *read generated plans*. A team that auto-approves Atlas plans without looking is more dangerous than a team writing careful golang-migrate files. A team drowning in hand-written deltas is better served by Atlas generating and linting them. There's no universally right answer - there's the one that matches your review culture.

## In the wild

The pattern that survives contact with real on-call: never run rollbacks against production data, gate every migration behind code review, run `atlas migrate lint` (or your own destructive-change check) in CI, and keep migrations *small* so a failure leaves a small mess. golang-migrate's dirty flag and Atlas's checksum both exist for the same reason - to stop a tired human from applying a broken or tampered history. Treat their refusals as the tool doing its job, not an obstacle to `force` past.

For the principles underneath both tools - ordering, idempotency, forward-only discipline - see [/guides/database-migrations](/guides/database-migrations).

```quiz
[
  {
    "q": "What does golang-migrate's `dirty` flag mean, and what does `force` do?",
    "choices": [
      "Dirty means uncommitted git changes; force commits them",
      "Dirty means a migration failed partway; force sets the recorded version without running SQL, so you must verify the real schema first",
      "Dirty means the database is locked; force unlocks it and re-runs everything",
      "Dirty means a checksum mismatch; force regenerates the checksum"
    ],
    "answer": 1,
    "explain": "A failed migration leaves the database dirty. `force N` only updates the bookkeeping; you must manually confirm the schema is truly at N before using it."
  },
  {
    "q": "Why does Atlas refuse to run when a migration file's checksum doesn't match `atlas.sum`?",
    "choices": [
      "Because the database connection failed",
      "Because an already-applied migration file was edited, and applying a tampered history is unsafe",
      "Because the dev database is missing",
      "Because the file is too large"
    ],
    "answer": 1,
    "explain": "Atlas checksums the migration directory. Editing a shipped migration breaks the checksum, and Atlas hard-stops rather than apply a mutated history. Add a new migration instead."
  },
  {
    "q": "What is the key safety practice for Atlas declarative `schema apply` in production?",
    "choices": [
      "Always pass --auto-approve to avoid prompts",
      "Read and review the generated plan (and lint for destructive changes) before approving, since removing schema can plan a data-destroying DROP",
      "Delete the dev database first",
      "Run it twice to be sure"
    ],
    "answer": 1,
    "explain": "Declarative apply does exactly what the schema file says - including DROPs for things you removed. Reviewing the plan and linting for destructive changes is what keeps that safe."
  }
]
```
