A new atlas migrate drift command compares a database against the state its migration directory defines at the last applied version, reports every object that diverged with the DDL that would reproduce it, and exits non-zero when drift is found.
atlas migrate drift reads the revisions table on the connected database, resolves the state the migration directory defines at the last applied version, and diffs the two. Files after that version are pending, not drift, and the report counts them as ignored. The command never changes the database. It takes the same advisory lock as atlas migrate apply while it reads the revisions table and inspects the schema, so an in-flight deployment is not reported as drift.
The command is available to Atlas Pro users. Run atlas login to create an account or start a free trial. The existing pre-apply drift check inside atlas migrate apply now shares the same implementation.
Running the Command
Point it at the database and the migration directory. When the directory is local and not stored in the Atlas Registry, a dev database is required:
$ atlas migrate drift --url "$DATABASE_URL" --dir file://migrations --dev-url "docker://postgres/17/dev"Drift Status: OK-- Current Version: 2-- Expected State: file://migrations (local)-- Pending Files: 1 (ignored)
Now suppose someone runs these statements directly against the database, bypassing the migration directory:
ALTER TABLE authors ADD COLUMN nickname text;CREATE INDEX authors_name ON authors (name);DROP TABLE logs;CREATE TABLE audit (id int PRIMARY KEY);
The next run reports the drift and exits with status 1. The report starts with a short diff of the two states, then lists the changes grouped by object with the DDL that reproduces each:
$ atlas migrate drift --url "$DATABASE_URL" --dir file://migrations --dev-url "docker://postgres/17/dev"Drift Status: DRIFTED-- Current Version: 2-- Expected State: file://migrations (local)-- Pending Files: 1 (ignored)-- Changes: 3 (1 extra, 1 missing, 1 modified)-- Objects: 3 tables-- Fingerprint: 28274a91e01d--- expected state (version 2)+++ actual state (postgres://localhost:5432/app)@@ -1,7 +1,8 @@CREATE TABLE "authors" ("id" integer NOT NULL,"name" text NOT NULL,"email" text NULL,+ "nickname" text NULL,PRIMARY KEY ("id"));+CREATE INDEX "authors_name" ON "authors" ("name");The database diverged from the expected state as if the following were executed:-- modified table "authors":-> ALTER TABLE "authors" ADD COLUMN "nickname" text NULL;-> CREATE INDEX "authors_name" ON "authors" ("name");-- missing table "logs":-> DROP TABLE "logs";-- extra table "audit":-> CREATE TABLE "audit" ("id" integer NOT NULL, PRIMARY KEY ("id"));Error: database state does not match expected state at version 2
Changes are classified from the database's point of view as extra, missing, or modified. Use the JSON format below for the full change list.
Where the Expected State Comes From
The report names the mode it used on the Expected State line:
- registry: when the directory is an atlas:// URL or migration.repo.name is set, the state of the applied version is fetched from the Atlas Registry by version and hash.
- local: otherwise, or when the registry holds no state for that version and --dev-url is given, the expected state is computed from the directory on the dev database up to the applied version.
With a directory pushed to the registry, the env needs only the URL and the directory:
env "prod" {url = env("DATABASE_URL")migration {dir = "atlas://my-app"}}
Cached Expected State
Both modes cache the resolved state, so periodic runs neither compute the state again nor call the registry every time. A cache hit is marked on the report as (local, cached) or (registry, cached), and the JSON report carries "Cached": true. Pass --no-cache to bypass it.
The cache defaults to file://~/.atlas/cache. The new cache block in the atlas block sets its location, and any blob URL works, so one bucket can be shared between machines and CI runners:
atlas {cache {dir = "s3://my-bucket/atlas-cache?region=us-east-1"}}env "prod" {url = env("DATABASE_URL")migration {dir = "atlas://my-app"}}
Machine-Readable Reports
--format accepts a Go template over the report, and the same template can be set in the env with format.migrate.drift. {{ json . }} emits the full report with the fields URL, Dir, Mode, Version, Pending, Drifted, Fingerprint, Summary, Changes, and Cached. Unlike the text report, it carries every change:
$ atlas migrate drift --env prod --format '{{ json .Summary }}'{"Total":3,"Extra":1,"Missing":1,"Modified":1,"Types":{"table":3}}
$ atlas migrate drift --env prod --format '{{ json .Changes }}'[{"Type": "table","Kind": "modified","Object": "table \"authors\"","Cmds": ["ALTER TABLE \"authors\" ADD COLUMN \"nickname\" text NULL","CREATE INDEX \"authors_name\" ON \"authors\" (\"name\")"]},...]
Flags
The command takes the same connection and directory flags as atlas migrate apply, and each has its env equivalent in atlas.hcl:
| Flag | Env attribute | Notes |
|---|---|---|
| --url | url | Required. The database to check. |
| --dev-url | dev | Dev database, required for a local directory. |
| --dir | migration.dir | Defaults to file://migrations. |
| --exclude | migration.exclude, else exclude | Objects maintained outside the migration flow. The revisions table is always excluded. |
| --format | format.migrate.drift | Go template for the report. |
| --lock-timeout | migration.lock_timeout | Defaults to 10s. |
| --lock-name | migration.lock_name | Advisory lock name shared with migrate apply. |
| --skip-lock | migration.skip_lock | Skip the advisory lock. |
| --no-cache | - | Bypass the expected-state cache. |
--dir-format and --revisions-schema are also accepted and behave as they do in atlas migrate apply. For the pre-apply variant of the same check, including on_error and per-environment behavior, see the drift detection doc.