Detecting Production Schema Drift Against a Desired State
A deploy fails in staging with index "idx_orders_status" already exists, and nobody on the team created it. Six weeks ago, during an incident, someone added that index directly on production to rescue a slow query, and later copied it to staging by hand. It was never added to the schema definition or the migrations. Now the repository, staging and production each describe a slightly different database, and the next migration that touches the table has become unpredictable. Drift like this is unavoidable in any team that fixes incidents under pressure; the failure is not noticing it. This guide turns the diff at the heart of Declarative Schema Management into a scheduled audit that reports drift within the hour, classifies it, and routes each kind to the right fix.
Symptom / Error Signatures
Drift announces itself indirectly long before anyone runs a comparison:
- Migrations fail in one environment but not another:
relation "idx_orders_status" already exists,column "region" of relation "orders" already exists, or MySQLERROR 1061 (42000): Duplicate key name. - Query plans differ between staging and production for the same query, because an index exists in one and not the other.
- A declarative tool proposes changes on a branch that did not touch the schema.
pg_index.indisvalid = falserows exist — invalid indexes left by failed concurrent builds, as described in cleaning up invalid indexes after a failed build.- A migration tool’s history table says a version is applied, but the object it created is missing — the signature of a half-applied non-transactional migration.
Root Cause Analysis
Drift arises whenever the schema is changed through any path other than the managed one: an incident console session, a DBA running maintenance, a cloud provider’s automatic index advisor, an extension creating its own tables, or a migration that failed partway on an engine or statement that cannot roll back. On MySQL every DDL statement commits implicitly, so a three-statement migration that fails on the second leaves the first applied; on PostgreSQL the same happens with CREATE INDEX CONCURRENTLY and any file run outside a transaction.
The detection principle is simple: compare what production is with what it should be, on a schedule, and treat any difference as an event. Declarative tools perform exactly this comparison. The work is in making the comparison quiet when nothing is wrong — excluding objects you deliberately do not manage and normalising on a dev database of the same version — so that when it does speak, people listen.
Immediate Mitigation
1. Take a baseline diff now. Run the comparison against a replica and save the output; it is your drift inventory.
# Shell · operator workstation or CI · read-only replica credentials · Docker for the dev DB
atlas schema diff \
--from "postgres://readonly:***@prod-replica:5432/app?sslmode=require" \
--to "file://schema.sql" \
--dev-url "docker://postgres/16/dev" > drift-baseline.sql
wc -l drift-baseline.sql
For MySQL with Skeema, skeema diff production prints the differences and exits non-zero when any exist; with a versioned workflow and no declarative tool, compare pg_dump --schema-only output from production with a scratch database built from migrations.
2. Check for invalid indexes and half-applied migrations first. These are the drifts that break the next deploy.
-- PostgreSQL · read-only · run on the primary or a replica
SELECT n.nspname AS schema, c.relname AS index_name, t.relname AS table_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid;
3. Triage each difference into adopt, revert or exclude. Adopt hotfixes that should stay by adding them to the desired state (and, in a hybrid workflow, generating a migration guarded with IF NOT EXISTS so other environments converge). Revert unauthorised changes with a reviewed migration. Exclude objects that are managed elsewhere.
4. Make the adoption idempotent everywhere. Because the object already exists in production, the migration that adopts it must not fail there.
-- PostgreSQL · adoption migration · non-transactional because of CONCURRENTLY
-- WARNING: IF NOT EXISTS matches by name only; confirm the existing index has the same definition.
SET lock_timeout = '2s';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_status ON orders (status);
-- ROLLBACK PATH: DROP INDEX CONCURRENTLY IF EXISTS idx_orders_status;
Permanent Fix / Long-Term Pattern
Schedule the diff and give it an owner. An hourly job against a read replica, with exclusions for unmanaged schemas and extension objects, posts any non-empty result to the team channel along with the plan that would reconcile it. Treat alerts like failing tests: each one is either adopted into the desired state, reverted, or excluded, within a day. Add the same check as a pre-deploy gate, so a migration never runs against a database whose starting state differs from what the migration was written for — the approach behind ensuring environment parity between dev and prod.
Reduce the sources of drift as well as detecting them. Give incident responders a fast, sanctioned path for emergency DDL — a pre-approved migration template they can merge in minutes — so that console changes become the exception. Record any console change in the incident timeline so the drift alert that follows has an explanation. And prefer transactional or idempotent migrations so failures do not leave partial state, as described in how to write idempotent SQL scripts for safe deploys.
Verification Checklist
Frequently Asked Questions
Should drift be fixed by changing production or by changing the desired state? It depends on which one is right. A hotfix index that solved a real problem should be adopted into the desired state. An unauthorised change that alters behaviour should be reverted in production through a reviewed migration. The audit’s job is to force that decision, not to make it automatically.
Is it safe to run the drift diff against the primary? Inspection only reads catalog tables and takes no locks on application tables, so it is low-risk. Running it against a replica is still preferable because it adds no load and cannot interfere with anything on the primary, and physical replicas carry an identical schema.
How do I handle objects created by extensions?
Exclude them from the comparison. Extensions such as PostGIS or pg_stat_statements create tables, views and functions that are managed by CREATE EXTENSION, not by your schema definition; comparing them only creates noise.
Can a versioned-migration team detect drift without a declarative tool?
Yes. Build a scratch database by replaying all migrations, dump both it and production with pg_dump --schema-only or mysqldump --no-data, normalise the dumps, and diff them. A declarative tool simply does this more precisely, because it compares structured models rather than text.