Every team eventually gets burned by schema drift.
A migration passes in CI, looks fine in review, and then blows up in production because production is not actually in the state everyone thought it was. Maybe someone ran an ALTER TABLE during an incident. Maybe a DBA added an index to calm down a slow query. Either way, your migration history says one thing, and the database says another.
The arcnull-hq/schema-drift-action is meant to catch that before a pull request gets merged. It compares the schema changes introduced by your PR against the real state of your target database and flags anything that could break or drift from what your migrations expect.
In this walkthrough I will show you how to set it up in GitHub Actions, how to configure it safely for PostgreSQL, and what to look for when it reports drift.
What you need before you start
A few basics need to be in place:
- A PostgreSQL database to compare against — usually production or staging
- A read-only PostgreSQL user the action can connect with
- That connection string stored as a GitHub Actions secret
- Migration files in your repository — Flyway, Liquibase, Alembic, or plain SQL all work
The action only reads schema metadata from PostgreSQL system catalogs. It does not need write access to anything.
Step 1: Create a read-only database user
The action needs to inspect pg_catalog to understand the current schema state. Give it a dedicated user with the minimum access it actually needs:
CREATE ROLE schema_drift_reader
WITH LOGIN PASSWORD 'your-secure-password';
GRANT CONNECT ON DATABASE your_database
TO schema_drift_reader;
GRANT USAGE ON SCHEMA public
TO schema_drift_reader;
Note: pg_catalog is readable by all PostgreSQL users by default — no explicit GRANT is needed. The above three statements are sufficient.
Store the connection string as a GitHub Actions secret named DRIFT_DATABASE_URL.
Step 2: Add the workflow file
Create .github/workflows/schema-drift.yml:
name: Schema Drift Check
on:
pull_request:
paths:
- 'src/main/resources/db/migration/**'
- 'migrations/**'
- 'alembic/versions/**'
- 'sql/**'
jobs:
schema-drift-check:
name: Detect Schema Drift
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run Arcnull Schema Drift Scanner
uses: arcnull-hq/schema-drift-action@v1
with:
database-url: ${{ secrets.DRIFT_DATABASE_URL }}
migration-path: src/main/resources/db/migration
schema: public
fail-on: breaking
The paths filter matters more than people think. It keeps the workflow from running on every single PR and limits it to changes that actually touch migrations. That saves CI time and keeps the signal cleaner — you do not want drift alerts on a PR that only changed a README.
Step 3: Configure the inputs
Required
database-url — the PostgreSQL connection string for the database you want to compare against.
migration-path — path to your migration files, relative to the repository root.
Optional
schema — PostgreSQL schema to scan. Defaults to public.
fail-on — controls how strict the check is.
migration-tool — one of flyway, liquibase, alembic, or auto.
ignore-patterns — comma-separated object name patterns to exclude from the check.
Understanding fail-on
This is the setting teams spend the most time thinking about, so it is worth being specific.
any — fail the PR for any detected drift at all. This is the strictest option. It makes sense when your team wants every schema change to flow through migrations with no exceptions, period.
breaking — fail only when the drift is likely to make the PR's migrations break. Missing tables, conflicting constraints, columns that already exist when the migration assumes they do not. Extra indexes or non-blocking columns still get reported but do not stop the merge. This is probably the right default for most teams.
none — never fail the check, just report what it finds. A good rollout setting when you want visibility before you start enforcing anything.
If you are not sure where to start, use none first. See what your environment actually looks like before deciding how strict to be.
Step 4: Read the output
The action produces three kinds of feedback.
PR check status
The workflow passes or fails. With fail-on: breaking, a breaking drift finding fails the check. If you have branch protection rules that require this check to pass, the PR cannot be merged until the issue is addressed.
PR annotations
The action adds annotations directly to the migration files in the PR, pointing to the exact line where the migration assumes a schema state that no longer matches reality. Instead of a vague failure, you get a concrete pointer tied to the SQL in question.
Drift report comment
The action posts a summary comment on the PR:
## Schema Drift Report
**Database:** production (postgres://...@prod-db:5432/myapp)
**Schema:** public
**Scan time:** 342ms
### Breaking Changes (1)
| Object | Expected | Actual | Impact |
|--------|----------|--------|--------|
| `users.email_verified` | NOT EXISTS | `boolean DEFAULT false` | Migration V42 assumes column does not exist and will fail on ADD COLUMN |
### Warnings (2)
| Object | Expected | Actual | Impact |
|--------|----------|--------|--------|
| `idx_orders_created_at` | NOT EXISTS | `btree (created_at)` | Untracked index, no migration impact |
| `payments.processor_ref` | NOT EXISTS | `text` | Untracked column, no migration impact |
**Recommendation:** Resolve the 1 breaking change before merging. Create a migration that accounts for the existing `users.email_verified` column, or remove it from production if it was added in error.
The thing that makes this useful is the separation between "this will actually break deployment" and "this is drift you should probably clean up." Not every mismatch needs to block a PR. The dangerous ones absolutely should.
Step 5: Make it required with branch protection
Once you trust the signal, make the check required.
Go to your repository Settings → Branches → edit the protection rule for main → enable Require status checks to pass before merging → add Detect Schema Drift as a required check.
After that, PRs with breaking drift cannot be merged until someone resolves it.
What to do when drift is detected
The migration is wrong
Sometimes the problem is that the migration assumes a clean state that no longer exists. Make it more defensive:
-- Instead of:
ALTER TABLE users ADD COLUMN email_verified boolean DEFAULT false;
-- Use:
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users'
AND column_name = 'email_verified'
) THEN
ALTER TABLE users
ADD COLUMN email_verified boolean DEFAULT false;
END IF;
END $$;
This is especially useful when cleaning up legacy drift across multiple environments that have diverged over time.
The production change was intentional
A DBA added an index to fix a slow query. The change was deliberate but never made it back into versioned migrations. Create a migration that documents it:
-- V43__document_existing_index.sql
CREATE INDEX IF NOT EXISTS idx_orders_created_at
ON orders (created_at);
Safe to run whether the object exists already or not. Migration history catches up with reality.
The production change was accidental
If the drift came from an unintended manual change, revert it in production and restore alignment with your migration history.
Be careful here. Before removing anything, verify that nothing — no application code, no reporting job, no operational script — started depending on the accidental change.
Ignoring known drift
Some drift is expected and permanent. Monitoring infrastructure, extension-managed objects, things your application does not own. Tell the action to skip them:
- name: Run Arcnull Schema Drift Scanner
uses: arcnull-hq/schema-drift-action@v1
with:
database-url: ${{ secrets.DRIFT_DATABASE_URL }}
migration-path: src/main/resources/db/migration
fail-on: breaking
ignore-patterns: "pg_stat_%,pganalyze_%,idx_monitoring_%"
Note: patterns use SQL LIKE syntax, not glob syntax. Use % as the wildcard, not *.
Use ignore lists sparingly. They grow. Every pattern you add is one more place drift can hide undetected.
Common issues
Action times out connecting to the database Your database firewall may be blocking GitHub Actions IP ranges. Add the GitHub Actions IP ranges to your database allowlist, or use a self-hosted runner inside your VPC.
Action reports drift that was just resolved The action scans the live database at PR time. If drift was fixed after the PR was opened, close and reopen the PR to trigger a fresh scan.
Patterns not matching in ignore-patterns
Use % not *. SQL LIKE syntax, not glob syntax. pg_stat_% works. pg_stat_* does not.
Wrapping up
Schema drift checks feel optional right up until the day they save you from a bad production migration. Catching drift in a PR is a lot cheaper than discovering it mid-deploy, and considerably less stressful than debugging a migration failure at 2 AM.
The action handles the tedious work — reading the catalog, comparing expected versus actual state, reporting the differences where your team already works. A sensible way to roll it out is to start with fail-on: none, clean up what you find, and then move to fail-on: breaking once the noise is under control.
That gives you a smoother adoption path and a much better chance of making schema checks something the team actually keeps enabled.
For continuous monitoring beyond CI — scheduled scans, Slack alerts, and historical drift tracking — Drift Scanner handles all of that.