Skip to content

What Migration Linters Catch Before Production

Reproduce lost column values, blocked writers, and a failing NOT NULL change, then compare the database results with Ptah's warnings and exit codes.

Ptah helps you plan, review, and apply database migrations. Try in your browser

A migration can parse, apply on an empty database, and still break a running application. Dropping a column destroys its values. An index build can queue writers. A required column can reject the rows already in the table.

We reproduced these cases with Ptah 0.11.4 and PostgreSQL 18.6. Each started from the same small customer table, in a separate disposable database. We ran the linter, executed the change, and checked what happened to the data and database sessions. The supporting files contain the migration pairs, session SQL, findings, and complete command results.

The default lint threshold is error. A warning appears in the report but does not fail the command. That mattered in this run:

Migration Findings Default exit --fail-on any exit
Drop a column and add its replacement DS102 error, BC104 warning 1 1
Rename the column in place BC101 warning 0 1
Build an ordinary index; drop it in the down file PG101, PG106 warnings 0 1
Add a required column without a default DD101 warning 0 1
Create and drop the index concurrently, outside a transaction None 0 0

For a review gate that must stop on those warnings, we used:

Terminal window
ptah migrations lint --dir index-blocking --dialect postgres --fail-on any

The explicit dialect selects PostgreSQL rules. This run inspected local SQL without a dev database. It did not execute migrations or inspect production rows. The reports also warned about missing baseline-schema inputs, including for the concurrent-index directory with no findings. An offline report does not establish that every analyzer had all its inputs.

A replacement column does not inherit the old values

Section titled “A replacement column does not inherit the old values”

Suppose the application changes full_name to display_name. This migration expresses a different operation:

examples/rename-loss/1789998922_rename_customer.up.sql
ALTER TABLE app.customers DROP COLUMN full_name;
ALTER TABLE app.customers ADD COLUMN display_name text;

Ptah reported DS102, an error about permanently deleting column data. It also reported BC104, because deployed code can still use the old name. Both the default and strict lint commands returned 1.

The deployment command supplied another boundary: migrations up refused the pending destructive change and returned 2. We explicitly allowed it on the disposable database to measure the result. Both customer rows survived, but both names became NULL.

The intended database operation preserved the values:

examples/rename-preserve/1789998964_rename_customer.up.sql
ALTER TABLE app.customers RENAME COLUMN full_name TO display_name;

After applying it, the new column still contained Ada Lovelace and Grace Hopper. The linter continued to report BC101: preserving values does not preserve the old column name. We ran the old application’s SELECT full_name query and PostgreSQL rejected it.

An index can block writers before its scan starts

Section titled “An index can block writers before its scan starts”

The ordinary index migration was one statement:

examples/index-blocking/1789998965_index_customer_email.up.sql
CREATE INDEX customers_email_idx ON app.customers (email);

PG101 warned about the write-blocking build. Its down file also received PG106 for an ordinary index drop. The default lint command returned zero; the strict command returned one.

To test the warning, we kept an update transaction open in one session and started ptah migrations up in another. PostgreSQL showed the index waiting for a ShareLock. We then inserted an unrelated customer from a third session. The recorded lock queue was:

Session Table lock Granted Waiting for
Existing writer RowExclusiveLock Yes The client to finish its transaction
Index migration ShareLock No Existing writer
New writer RowExclusiveLock No Index migration

The new insert hit its three-second lock timeout. Committing the original transaction let the migration finish and left a valid index. A large table was unnecessary: the waiting lock request was enough to affect new work.

We repeated the same sequence with:

examples/index-concurrent/1789998966_index_customer_email.up.sql
-- +ptah no_transaction
CREATE INDEX CONCURRENTLY customers_email_idx ON app.customers (email);

The index held ShareUpdateExclusiveLock and waited for the old transaction, but the new insert succeeded. After releasing the old writer, the migration completed and the index was valid and ready. These observed results agree with PostgreSQL’s concurrent index documentation.

The transaction marker matters. Removing it produced PG103, and executing that variant returned 2 because Ptah refused the concurrent operation inside a migration transaction. The corrected directory uses concurrent operations and the marker in both directions; strict lint returned zero. We tested the up path, not the down path’s behavior under load.

Concurrent creation still did work and waited for a transaction. This small fixture demonstrates lock compatibility, not production build time or absence of CPU and I/O contention.

Existing rows decide whether NOT NULL can succeed

Section titled “Existing rows decide whether NOT NULL can succeed”

Our populated table rejected this change:

examples/not-null-direct/1789998968_require_segment.up.sql
ALTER TABLE app.customers ADD COLUMN segment text NOT NULL;

The linter reported DD101. Again, default lint returned zero and strict lint returned one. Applying the migration returned 2 with PostgreSQL’s 23502 error: the column contained null values. The transaction left the original rows intact and did not add the column.

In a separate database, we applied staged migrations: add a nullable column, backfill the existing rows with the fixture’s free segment, then add and validate a check before setting NOT NULL. All migrations applied. Both names were preserved, both rows had a segment, and a later insert without one failed.

That staged directory still produced a PG303 warning in the tested version. We retained it and the strict exit code of one. The completed database run supplies evidence about this fixture; it does not turn every remaining static warning into an automatically approved exception. The backfill was a small lab update, with no claim about production batching or a concurrent application rollout.

The linter identified concrete hazards before we executed them. Its exit policy determined whether warnings would stop a build. Runtime checks then answered different questions: whether names survived, whether an old query still worked, which session blocked a writer, and whether existing rows met a constraint.

A clean concurrent-index report did not measure the workload. A successful rename did not keep old callers working. Keep those checks beside the migration review, and use the recorded failure to decide what the rollout must change.

Put Ptah to work

Example files