Database Schema Drift Detection for Versioned Migrations
Database schema drift happens when the target database diverges from the source of truth: the
version-controlled schema and migration files your code was written against. Atlas detects
drift in two complementary ways: continuous schema monitoring
that runs asynchronously in the background, and a synchronous pre-apply check
that runs at the start of atlas migrate apply.
This page covers the pre-apply schema drift check, which verifies that the target database
has not been changed unexpectedly since the last applied migration. The check guarantees
deterministic behavior at deploy time and prevents atlas migrate apply from failing
mid-flight on a drifted schema.
Pre-apply drift detection is available to Atlas Pro users. You can create a
trial account using the atlas login command.
Overview
The declarative and versioned workflows handle planning very differently, which is why drift is particularly dangerous in versioned deployments. Declarative apply re-inspects the target database on every run and either uses a pre-planned migration for the requested transition or computes one on the fly, so drift is absorbed at plan time.
Versioned migrations move planning to dev and CI (atlas migrate diff, atlas migrate lint).
At apply time, atlas migrate apply executes the next pending migration files in order,
trusting the revisions table and assuming the target database has not been changed outside
of Atlas since the last applied migration. When that assumption holds, deployments are fast,
predictable, and deterministic. When it breaks, a migration written against an expected
state can fail mid-flight, partially apply, or silently produce a different result.
The pre-apply drift check verifies that assumption before any migration file runs.
Setup
Prerequisites
- Ensure your migration directory is pushed to the Atlas Registry during continuous delivery. The registry provides the expected state for each version that the drift check compares against.
- At least one revision must already be applied. On a fresh database with no revisions, the check is skipped.
Configuration
The check is configured inside a check "migrate_apply" block in atlas.hcl, alongside any
other pre-execution checks:
env "prod" {
url = env("DATABASE_URL")
migration {
dir = "atlas://my-app"
}
check "migrate_apply" {
drift {
on_error = FAIL
}
}
}
With this configuration, every atlas migrate apply --env prod first runs the drift check.
If the target database has drifted from the expected state at the latest applied revision,
the apply is aborted before any migration file runs.
How It Works
- Atlas reads the latest applied revision from the database's revisions table.
- It fetches the expected state for this revision from the Atlas Registry.
- It inspects the target database.
- It diffs the expected state against the actual state. If they differ, the migration is
blocked (or a warning is emitted when
on_error = CONTINUE).
Enabling on Existing Environments
When enabling drift detection on an existing environment, start with on_error = CONTINUE
rather than FAIL. This surfaces any pre-existing drift in the apply transcript without
blocking the deployment, so the first run does not fail unexpectedly on objects that may
have intentional deviations from the migration directory:
check "migrate_apply" {
drift {
on_error = CONTINUE
}
}
Review the diff Atlas reports and triage:
- Unintentional drift (someone ran an out-of-band
ALTER, an old hotfix was never folded back) should be remediated by adding a migration that brings the schema back in line. - Intentional drift (extensions installed manually, audit or sidecar tables managed by
another tool, columns added by a separate service) should be added to
excludeso the drift check ignores them on every subsequent run. See Excluding Objects for patterns and the full list of use cases.
Once the diff is clean, switch on_error to FAIL to make drift a hard stop on production
deployments.
Examples
- Drift detected
- No drift
- Skipped
When the target database differs from the expected state at the latest revision, the apply is aborted before any migration file runs:
Executing pre-execution check (1 check in total):
-- check at atlas.hcl:10 (drift):
-> check drift against version 20260423120000
--- expected state
+++ actual state
@@ -0,0 +1,3 @@
+CREATE TABLE "audit_log" (
+ "id" integer NULL
+);
Error: database state does not match expected state at version "20260423120000"
-------------------------------------------
database state does not match expected state at version "20260423120000"
The diff is rendered in the apply transcript so the operator can see exactly which objects drifted. The migration files themselves are never executed.
When the database matches the expected state, the check passes and the migration proceeds:
Executing pre-execution check (1 check in total):
-- check at atlas.hcl:10 (drift):
-> check drift against version 20260423120000
-- passed
-- ok (12.4ms)
-------------------------------------------
Migrating to version 20260423130000 from 20260423120000 (1 migrations in total):
-- migrating version 20260423130000
-> ALTER TABLE users ADD COLUMN email text;
-- ok (4.2ms)
On a fresh database, or when the latest revision is not in the registry, the check is skipped and the apply continues:
Executing pre-execution check (1 check in total):
-- check at atlas.hcl:10 (drift):
-> check drift
-- skipped
-- ok (1.1ms)
-------------------------------------------
Migrating to version 20260423120000 (1 migrations in total):
...
Continuing on Drift
For staging or canary environments where drift may exist by design and you want visibility
without a hard stop, set on_error = CONTINUE. Atlas prints the diff on every apply so the
deployment log captures the divergence, but the migration proceeds.
If you share a single check "migrate_apply" block across environments, switch on
atlas.env so production still aborts on drift while non-production environments only warn:
check "migrate_apply" {
drift {
on_error = atlas.env == "prod" ? FAIL : CONTINUE
}
}
Excluding Objects
exclude lets you ignore database objects that intentionally live outside the migration
scope. Common examples:
- Extensions installed manually (PostGIS, pgcrypto, vector indexes for an external service).
- Audit, log, or sidecar tables maintained by a separate service.
- Schemas owned by another team or another tool.
- Temporary or test objects created by jobs that run between migrations.
Without exclude, every one of these would be reported as drift on every apply.
The pattern format depends on the URL scope of env.url:
- Schema Scope
- Database Scope
When the URL points to a single schema (e.g., in PostgreSQL with search_path set), each
pattern matches an object name within that schema:
check "migrate_apply" {
drift {
exclude = [
"audit_log", // a single table
"monitoring_*", // tables matching a prefix
"t*[type=table]", // all tables matching a prefix
"*[type=policy|function]", // all policies and functions
]
}
}
When the URL points to a multi-schema database, patterns can target two scopes:
- Database-level objects (extensions, schemas, roles) match by name:
*[type=extension],internal. - Objects within a schema match using
schema.objectnotation:public.audit_log,internal.*.
check "migrate_apply" {
drift {
exclude = [
"*[type=extension]", // all extensions (database-level)
"internal", // an entire schema by name
"internal.*", // every object in schema "internal"
"public.audit_log", // a single table by qualified name
"public.monitoring_*", // tables matching a prefix in "public"
"*.*[type=partition]", // all partitions across all schemas
]
}
}
For the full glob syntax and [type=...] selectors, see the
--exclude flag reference.
When drift.exclude is set, it replaces env.exclude for the drift check rather than
extending it. When unset, env.exclude is used as the fallback. The revisions table and its
containing schema are excluded automatically.
See Also
- Pre-Execution Checks: the parent feature. The
driftblock lives alongsideallowanddenyrules. - Schema Monitoring: Drift Detection: continuous, out-of-band drift monitoring.
- Atlas Registry: the source of expected state used by the pre-apply check.