Back to changelog
New
4 minute read

Schema Drift Detection for Versioned Migrations: atlas migrate drift

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:

atlas.hcl
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.hcl
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:

FlagEnv attributeNotes
--urlurlRequired. The database to check.
--dev-urldevDev database, required for a local directory.
--dirmigration.dirDefaults to file://migrations.
--excludemigration.exclude, else excludeObjects maintained outside the migration flow. The revisions table is always excluded.
--formatformat.migrate.driftGo template for the report.
--lock-timeoutmigration.lock_timeoutDefaults to 10s.
--lock-namemigration.lock_nameAdvisory lock name shared with migrate apply.
--skip-lockmigration.skip_lockSkip 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.

featuredrift-detectionversioned-migrations