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.
Choose what makes the build fail
Section titled “Choose what makes the build fail”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:
ptah migrations lint --dir index-blocking --dialect postgres --fail-on anyThe 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:
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:
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:
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:
-- +ptah no_transactionCREATE 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:
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.
Keep lint and runtime evidence together
Section titled “Keep lint and runtime evidence together”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
- holder.sql
- index-blocking/1789998965_index_customer_email.down.sql
- index-blocking/1789998965_index_customer_email.up.sql
- index-blocking/ptah.sum
- index-concurrent/1789998966_index_customer_email.down.sql
- index-concurrent/1789998966_index_customer_email.up.sql
- index-concurrent/ptah.sum
- index-transactional/1789998966_index_customer_email.down.sql
- index-transactional/1789998966_index_customer_email.up.sql
- index-transactional/ptah.sum
- locks.sql
- measured/commands.json
- measured/index-blocking-apply.txt
- measured/index-blocking-assert.txt
- measured/index-blocking-create-db.txt
- measured/index-blocking-final.txt
- measured/index-blocking-hash.txt
- measured/index-blocking-holder.txt
- measured/index-blocking-lint-any.txt
- measured/index-blocking-lint-default.txt
- measured/index-blocking-lint-json.txt
- measured/index-blocking-locks.txt
- measured/index-blocking-setup.txt
- measured/index-blocking-writer.txt
- measured/index-concurrent-apply.txt
- measured/index-concurrent-assert.txt
- measured/index-concurrent-create-db.txt
- measured/index-concurrent-final.txt
- measured/index-concurrent-hash.txt
- measured/index-concurrent-holder.txt
- measured/index-concurrent-lint-any.txt
- measured/index-concurrent-lint-default.txt
- measured/index-concurrent-lint-json.txt
- measured/index-concurrent-locks.txt
- measured/index-concurrent-setup.txt
- measured/index-concurrent-writer.txt
- measured/index-timeout-apply.txt
- measured/index-timeout-assert.txt
- measured/index-timeout-create-db.txt
- measured/index-timeout-setup.txt
- measured/index-transactional-apply.txt
- measured/index-transactional-assert.txt
- measured/index-transactional-create-db.txt
- measured/index-transactional-hash.txt
- measured/index-transactional-lint-any.txt
- measured/index-transactional-lint-default.txt
- measured/index-transactional-lint-json.txt
- measured/index-transactional-setup.txt
- measured/not-null-direct-apply.txt
- measured/not-null-direct-assert.txt
- measured/not-null-direct-create-db.txt
- measured/not-null-direct-data.txt
- measured/not-null-direct-hash.txt
- measured/not-null-direct-lint-any.txt
- measured/not-null-direct-lint-default.txt
- measured/not-null-direct-lint-json.txt
- measured/not-null-direct-setup.txt
- measured/not-null-staged-apply.txt
- measured/not-null-staged-assert.txt
- measured/not-null-staged-create-db.txt
- measured/not-null-staged-data.txt
- measured/not-null-staged-hash.txt
- measured/not-null-staged-lint-any.txt
- measured/not-null-staged-lint-default.txt
- measured/not-null-staged-lint-json.txt
- measured/not-null-staged-reject.txt
- measured/not-null-staged-setup.txt
- measured/postgres-image.json
- measured/postgres-version.txt
- measured/ptah-version.txt
- measured/rename-loss-apply-allowed.txt
- measured/rename-loss-apply.txt
- measured/rename-loss-assert.txt
- measured/rename-loss-create-db.txt
- measured/rename-loss-data.txt
- measured/rename-loss-hash.txt
- measured/rename-loss-lint-any.txt
- measured/rename-loss-lint-default.txt
- measured/rename-loss-lint-json.txt
- measured/rename-loss-refused-data.txt
- measured/rename-loss-setup.txt
- measured/rename-preserve-apply.txt
- measured/rename-preserve-assert.txt
- measured/rename-preserve-create-db.txt
- measured/rename-preserve-data.txt
- measured/rename-preserve-hash.txt
- measured/rename-preserve-lint-any.txt
- measured/rename-preserve-lint-default.txt
- measured/rename-preserve-lint-json.txt
- measured/rename-preserve-old-reader.txt
- measured/rename-preserve-setup.txt
- not-null-direct/1789998968_require_segment.down.sql
- not-null-direct/1789998968_require_segment.up.sql
- not-null-direct/ptah.sum
- not-null-staged/1789998969_add_segment.down.sql
- not-null-staged/1789998969_add_segment.up.sql
- not-null-staged/1789998970_backfill_segment.down.sql
- not-null-staged/1789998970_backfill_segment.up.sql
- not-null-staged/1789998971_require_segment.down.sql
- not-null-staged/1789998971_require_segment.up.sql
- not-null-staged/ptah.sum
- README.md
- rename-loss/1789998922_rename_customer.down.sql
- rename-loss/1789998922_rename_customer.up.sql
- rename-loss/ptah.sum
- rename-preserve/1789998964_rename_customer.down.sql
- rename-preserve/1789998964_rename_customer.up.sql
- rename-preserve/ptah.sum
- setup.sql
- verified.json
- writer.sql