Skip to main content

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:

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

  1. Atlas reads the latest applied revision from the database's revisions table.
  2. It fetches the expected state for this revision from the Atlas Registry.
  3. It inspects the target database.
  4. 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:

atlas.hcl
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 exclude so 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

When the target database differs from the expected state at the latest revision, the apply is aborted before any migration file runs:

Output
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.

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:

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

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:

atlas.hcl
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
]
}
}

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