Atlas (Ariga): Database Schema Management as Code and Migration Automation

Database tutorial - IT technology blog
Database tutorial - IT technology blog

If you’ve ever had to manually compare SQL migration files between staging and production to figure out “what’s missing,” you know exactly how painful that is. I’ve been through this more times than I’d like — especially on PostgreSQL projects with complex schemas, where the whole team was committing migrations without any proper tooling to manage them.

Atlas by Ariga was built to solve exactly that problem: manage your database schema like code (Schema as Code / IaC) — versioned, automatable, and consistent across every environment.

Three Approaches to Schema Management: A Practical Comparison

To understand what makes Atlas different, let’s quickly walk through the three most common approaches:

1. Manual migration files (SQL/ORM)

This is the most common approach — using Flyway, Liquibase, or ORM migrations (Alembic, Django migrations, Rails ActiveRecord). You write SQL files in order, commit them to git, and run migrate on deploy.

  • Pros: Familiar, well-documented, natively integrated with most ORMs
  • Cons: Migration files pile up over time, auditing the current schema is hard, conflicts are frequent when multiple people work on it simultaneously, and there’s no automatic drift detection between code and the actual database

2. Declarative schema (describe the desired state)

Instead of writing “what to do” (ALTER TABLE…), you write “what you want” — what the final schema should look like. The tool computes the diff and generates the migration automatically. This is how Terraform works for infrastructure; Atlas applies the same philosophy to databases.

  • Pros: Schema is always the source of truth, easy to read and review, no drift
  • Cons: Requires learning the tool; some edge cases need manual intervention

3. Atlas: A hybrid approach — the best of both worlds

Atlas supports both modes: declarative (schema.sql/HCL describes the desired end state) and versioned migration (generates auditable .sql files). You can choose whichever fits your workflow, or combine both depending on the project stage.

Deep Dive: Atlas vs Flyway/Liquibase

Criteria Flyway / Liquibase Atlas
Schema drift detection Not available Yes (atlas schema diff)
Declarative mode No Yes
CI/CD integration Moderate Strong (native GitHub Actions)
Migration linting Not available Yes (atlas migrate lint)
Multi-database support Yes (many) PG, MySQL, SQLite, MS SQL
Schema format SQL / XML / YAML HCL + SQL

My favorite feature in Atlas is atlas migrate lint — it analyzes migrations before applying them and warns about destructive operations (DROP TABLE, DROP COLUMN) or missing indexes on foreign keys. Flyway doesn’t have this, and I once accidentally dropped a column on staging because nobody reviewed it carefully enough.

When Should You Choose Atlas?

After using Atlas on several PostgreSQL production projects, I’ve found it fits best when:

  • A team of 3+ people is modifying the schema and needs to review and approve migrations
  • You have multiple environments (dev/staging/prod) and need schema consistency across all of them
  • You want to integrate migration checks into your CI/CD pipeline to block merges when issues are detected
  • Your project uses PostgreSQL or MySQL with a reasonably complex schema

On the other hand, if you’re working on a small solo project with Django or Rails, your framework’s ORM migrations are perfectly adequate — don’t over-engineer it.

Installing Atlas

Atlas is a single binary with no runtime dependencies:

# macOS
brew install ariga/tap/atlas

# Linux (one-liner)
curl -sSf https://atlasgo.sh | sh

# Check version
atlas version

Real-World Setup: Versioned Migrations with PostgreSQL

The workflow below is what I’m currently running in production. All examples use PostgreSQL 15.

Step 1: Create the project structure

mkdir myapp-db && cd myapp-db
mkdir -p migrations

Step 2: Create schema.sql — the source of truth

-- schema.sql: The "desired state" of the database
CREATE TABLE users (
  id          BIGSERIAL PRIMARY KEY,
  email       VARCHAR(255) NOT NULL UNIQUE,
  username    VARCHAR(100) NOT NULL,
  created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE TABLE posts (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  title       VARCHAR(500) NOT NULL,
  body        TEXT,
  published   BOOLEAN NOT NULL DEFAULT FALSE,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_published ON posts(published) WHERE published = TRUE;

Step 3: Generate the first migration file

atlas migrate diff initial_schema \
  --dir "file://migrations" \
  --to "file://schema.sql" \
  --dev-url "docker://postgres/15/dev"

The --dev-url "docker://postgres/15/dev" flag is quite handy — Atlas automatically spins up a temporary PostgreSQL container to compute the diff, so you don’t need Postgres installed locally. After running, you’ll have:

migrations/
  20260521000001_initial_schema.sql
  atlas.sum   # checksum file — commit this alongside migrations

The atlas.sum file is important: Atlas uses it to detect if anyone modifies an already-committed migration (similar to go.sum). Always commit this file to git.

Step 4: Apply the migration to your actual database

atlas migrate apply \
  --dir "file://migrations" \
  --url "postgres://user:pass@localhost:5432/myapp?sslmode=disable"

# Output:
# Migrating to version 20260521000001 (1 migration in total):
#   -- migrating version 20260521000001
#     -> CREATE TABLE users ...
#     -> CREATE TABLE posts ...
#   -- ok (23ms)

Step 5: Adding a new column — the schema change workflow

Product requests a new bio column on the users table. Instead of writing ALTER TABLE by hand, just edit schema.sql:

-- Add to the users table definition:
  bio  TEXT,

Then run migrate diff again:

atlas migrate diff add_user_bio \
  --dir "file://migrations" \
  --to "file://schema.sql" \
  --dev-url "docker://postgres/15/dev"

# Atlas automatically generates:
# ALTER TABLE "users" ADD COLUMN "bio" text;

Battle-Tested Tips

1. Run migrate lint before merging a PR

atlas migrate lint \
  --dir "file://migrations" \
  --dev-url "docker://postgres/15/dev" \
  --latest 1

# Atlas warns about destructive changes:
# L1: Dropping non-virtual column "email" (MF103)
# L2: Adding a non-nullable column "phone" to existing table (MF103)

Integrate this into GitHub Actions to block merges when destructive changes haven’t been properly reviewed:

# .github/workflows/atlas-lint.yml
name: Atlas Migration Lint
on:
  pull_request:
    paths: ['migrations/**', 'schema.sql']

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ariga/setup-atlas@v0
      - name: Lint migrations
        uses: ariga/atlas-action/migrate/lint@v1
        with:
          dir: 'file://migrations'
          dev-url: 'docker://postgres/15/dev'

2. Detect drift between schema code and the real database

# Compare the current DB schema against schema.sql
atlas schema diff \
  --from "postgres://user:pass@prod-host:5432/myapp?sslmode=require" \
  --to "file://schema.sql"

# If drift exists, the output clearly shows where the DB has diverged from code

Flyway doesn’t have this feature. I run this periodically in CI to catch cases where someone ALTER TABLEd directly on production — which happens more often than you’d think, especially during a 2am hotfix.

3. Use atlas.hcl to clean up your commands

# atlas.hcl
env "dev" {
  src = "file://schema.sql"
  url = "postgres://user:pass@localhost:5432/myapp?sslmode=disable"
  dev = "docker://postgres/15/dev"
  migration {
    dir = "file://migrations"
  }
}

env "prod" {
  url = getenv("DATABASE_URL")
  migration {
    dir = "file://migrations"
  }
}

With this file, commands become much shorter:

atlas migrate apply --env dev
atlas migrate apply --env prod

Common Errors and How to Fix Them

  • Checksum mismatch: Someone edited an already-committed migration file. Confirm the change is intentional, then run atlas migrate hash --force to regenerate the checksum.
  • docker:// not working: Docker needs to be running and Atlas needs socket access. Replace with a real dev database URL if Docker isn’t available.
  • Migration stuck due to advisory lock: If a previous process was killed mid-run, execute SELECT pg_advisory_unlock_all(); on PostgreSQL to release the lock.

Conclusion

Atlas isn’t the only database migration tool out there. But drift detection, destructive change linting, and CI/CD integration are things Flyway and Liquibase simply don’t do. I’ve migrated one PostgreSQL project from Alembic to Atlas — and I haven’t had a reason to go back, especially when the whole team needs to review migrations before merging.

If you’re setting up a new project, give Atlas a try from day one — 30 minutes of upfront setup will save you a lot of headaches later as your schema grows in complexity.

Share: