DEV Community

Cover image for Schema Diff: Catch Drift Between Staging and Production
Philip McClarence
Philip McClarence

Posted on

Schema Diff: Catch Drift Between Staging and Production

Schema Diff: Catch Drift Between Staging and Production

Your migration tool says everything is applied. Your CI pipeline is green. But production has a column that staging does not, and nobody knows who added it or when. Sound familiar? Schema drift is the gap between what your migration history says should exist and what actually exists in the database.

The Problem

A deployment fails in production because a migration expects a column that exists in staging but not production. Someone added it manually during debugging weeks ago and forgot to create a proper migration. Or the deployment succeeds, but a query starts failing because the index it depends on was only created in the staging environment. Or a constraint was dropped in production to fix a data issue and never re-added.

Schema drift between environments is one of those problems everyone accumulates and nobody actively monitors. Each drift is individually small — a missing column, a different default value, an index present in one environment but not the other. But they compound. By the time you discover them, it is during a deployment or an incident, when the cost of surprise is highest.

The drift is not always between staging and production. Replicas can drift from their primary if DDL is applied directly to one node. Development databases diverge as engineers apply ad-hoc changes. Even within a single environment, a failed migration that partially applied can leave the schema in an inconsistent state — half the tables have the new column, half do not.

Migration tools like Flyway, Liquibase, and Alembic track what migrations were applied, but they do not verify the actual schema matches the expected state. A migration marked as "applied" in the history table says nothing about whether someone later modified the schema by hand.

How to Detect It

Comparing schemas manually means querying information_schema on both databases and diffing the results. Start with tables and columns:

-- Compare columns between two schemas
-- Run on EACH database and diff the output
SELECT
    table_name,
    column_name,
    data_type,
    column_default,
    is_nullable,
    character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position;
Enter fullscreen mode Exit fullscreen mode

Then indexes:

-- List all indexes with their definitions
SELECT
    tablename,
    indexname,
    indexdef
FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY tablename, indexname;
Enter fullscreen mode Exit fullscreen mode

Then constraints, functions, triggers, and sequences. Each object type requires its own query, its own export, and its own diff. For a schema with 50 tables, this is hundreds of rows to compare across six or more categories. You can script it, but maintaining the script and interpreting the diff requires ongoing effort.

The deeper problem is that nobody does this proactively. Schema comparison is a reactive activity — you do it after something breaks, not before. The gap between "last time someone checked" and "now" is where drift accumulates undetected.

A Practical Approach to Schema Comparison

Whether you build custom tooling or use a monitoring platform, an effective schema diff should categorize differences by type: missing tables, missing columns, different column types or defaults, missing indexes, missing constraints, and missing functions. Color-coding by severity helps with triage — a missing column that exists in production but not staging is a deployment risk, while a different column default may be intentional.

Even better, generate the exact ALTER statements needed to reconcile each difference. A missing column should produce ALTER TABLE ADD COLUMN. A different default should produce ALTER COLUMN SET DEFAULT. A missing index should produce CREATE INDEX CONCURRENTLY. This eliminates the manual step of writing migration SQL.

Safe patterns for generated statements include IF NOT EXISTS where supported, CONCURRENTLY for index operations, and explicit data types to avoid ambiguity.

How to Fix It

Walk through the most common drift scenarios and their fixes:

Missing column — A column exists in staging but not production:

-- Add the missing column with a safe default
ALTER TABLE orders
    ADD COLUMN IF NOT EXISTS status_code integer DEFAULT 0;
Enter fullscreen mode Exit fullscreen mode

Use IF NOT EXISTS (PostgreSQL 9.6+) so the statement is idempotent — safe to run even if someone already added the column manually.

Different default value — The column exists in both environments but defaults differ:

-- Align the default value
ALTER TABLE orders
    ALTER COLUMN status_code SET DEFAULT 1;
Enter fullscreen mode Exit fullscreen mode

Missing index — An index exists in production but not staging (or vice versa):

-- Create the missing index without blocking writes
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_status_code
    ON orders (status_code);
Enter fullscreen mode Exit fullscreen mode

Always use CONCURRENTLY in production. A regular CREATE INDEX takes a ShareLock on the table, blocking all inserts and updates until the index is built.

Missing constraint — A foreign key or check constraint was dropped or never created:

-- Re-add a missing foreign key constraint
ALTER TABLE order_items
    ADD CONSTRAINT fk_order_items_order_id
    FOREIGN KEY (order_id) REFERENCES orders (order_id);
Enter fullscreen mode Exit fullscreen mode

Adding a foreign key validates all existing rows, which can be slow on large tables. Use NOT VALID to add the constraint without validating existing data, then validate separately:

ALTER TABLE order_items
    ADD CONSTRAINT fk_order_items_order_id
    FOREIGN KEY (order_id) REFERENCES orders (order_id)
    NOT VALID;

-- Validate existing rows separately (does not block writes)
ALTER TABLE order_items
    VALIDATE CONSTRAINT fk_order_items_order_id;
Enter fullscreen mode Exit fullscreen mode

How to Prevent It

Run schema diff as part of your deployment pipeline. Before applying migrations to production, compare the production schema against what your migration tool expects. This catches manual changes that were applied outside the migration system — the single most common source of drift.

Compare your primary against each replica periodically. Physical replication keeps schemas in sync, but logical replication does not — DDL is not replicated by default in logical replication setups. If you use logical replication, schema drift between publisher and subscriber is almost guaranteed without explicit management.

Establish a monthly schema comparison between staging and production even when no deployment is planned. Drift accumulates in the gaps between deployments.

Treat schema as code, but verify the code matches reality. Migration history says what should have been applied. Schema diff tells you what actually exists. The gap between the two is where incidents start.

Top comments (0)