Skip to content

Ptah 0.8.0: Change a Schema, Then Check What It Did

What Ptah 0.8.0 adds for changing a schema under a running application and checking afterwards what the change did, and where each part stops.

Ptah 0.8.0 is published on GitHub. The release is about one situation: a schema change runs while the application keeps reading and writing, and afterwards somebody has to say what the change did.

Each half can go wrong without an error. A migration can wait for a lock while every query on the table waits behind it. It can also commit, match the declared schema, and leave rows that its backfill missed. ptah migrations status tells you whether the history matches the directory, and ptah schema compare whether the structure matches the declaration. Neither reads the rows, and neither keeps a record of what happened.

The sections below take the two halves in that order, then list what you have to act on when you upgrade.

The examples below were run with the official 0.8.0 binaries against PostgreSQL 18.6, and the release example was repeated on SQLite. The example files hold the fixtures, the full output, and the checksums.

A promise of zero downtime depends on the row count, the traffic, the replication lag, and how the application rolls out. Ptah sees none of these, so the release does not make that promise. It works from the SQL instead. It writes a change in a form the server can apply without blocking, and asks the server to refuse a form it cannot. Separately, it limits how long a migration waits for a lock.

The first part is a key in ptah.yaml:

examples/online/ptah.yaml
diff:
online_alter: true

We added a CHECK constraint on accounts.plan to the desired schema and ran ptah migrations generate against PostgreSQL. With the key set, it wrote this migration:

examples/online/measured/1790115827_plan_known.up.sql
-- +ptah no_transaction
-- Migration generated from schema differences
-- Generated on: 2026-09-22T22:23:47Z
-- Direction: UP
-- ALTER statements: --
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_plan_known" CHECK (plan IN ('monthly', 'annual', 'trial')) NOT VALID;
ALTER TABLE "accounts" VALIDATE CONSTRAINT "accounts_plan_known";

NOT VALID adds the constraint without reading the existing rows. VALIDATE CONSTRAINT reads them afterwards, under a lock that lets reads and writes continue. Without the key, the same comparison wrote a single ADD CONSTRAINT, which checks every row while it holds the lock that blocks both. That file carries a three-second lock timeout, so its wait for the lock is bounded, but its scan runs under the lock.

The weaker lock only helps if the two statements commit separately. That is why the generated file is marked no_transaction, and the choice has a cost. Ptah sets a migration’s lock timeout on its transaction, and this file runs without one, so migrations up --lock-timeout 3s refuses it:

examples/online/measured/lock-timeout-refused.txt
error: error running migrations: migration 1790115827 is marked no_transaction, so migration timeouts cannot be applied safely

The first statement still takes PostgreSQL’s strongest table lock for a moment (see ALTER TABLE). We held the table in another transaction for ten seconds and applied the migration. It waited about eight seconds, until that transaction committed. A SELECT sent during the wait gave up at its own two-second lock timeout, because it was queued behind the migration. The same SELECT returned at once before the migration started.

So the generated form removes the long scan under the strong lock. It keeps the moment at the start, and that moment is short only when nothing else holds the table.

On MySQL and MariaDB the same key adds ALGORITHM=INPLACE, LOCK=NONE to every ALTER TABLE. The server then refuses a change it could only make by copying the table, and Ptah reports that refusal. We did not run that path for this post; the release notes describe it.

The linter has a matching mode. With online: require in .ptah-lint.yaml, it reports every statement it cannot prove runs online, including statements that no rule recognizes, and migrations up refuses to apply on those findings. The online mode states the exact property it proves. On the generated file it reported nothing to refuse.

In 0.8.0 the two features do not fit together cleanly. The default rule PG305 still warns about the NOT VALID line, although that is the form its own message recommends. On PostgreSQL the online mode also requires a lock timeout, which this file cannot take. Under the mode, migrations up refused the directory without --lock-timeout and refused the file with it. A dry run passed with --tx-mode none.

The release example adds a tier column and fills it from plan:

examples/release/migrations/1790100060_add_tier.up.sql
ALTER TABLE accounts ADD COLUMN tier TEXT;
UPDATE accounts SET tier = 'paid' WHERE plan IN ('monthly', 'annual');

The backfill misses the trial plan. After migrations up, migrations status --exit-code and schema compare --exit-code both exited 0. Both answers are correct, and neither is about the rows.

ptah db verify checks the rows. A release requirement is a -- +ptah check directive, the same one a migration carries as a precondition, kept in a file or a directory of files:

examples/release/release-checks/010_tier.sql
-- +ptah check name="every account has a tier" assert="SELECT COUNT(*) = 0 FROM accounts WHERE tier IS NULL"
-- +ptah check name="paid plans are on the paid tier" assert="SELECT COUNT(*) = 0 FROM accounts WHERE plan IN ('monthly', 'annual') AND tier <> 'paid'"
Terminal window
ptah db verify --db-url "$DATABASE_URL" --checks release-checks/
examples/release/measured/verify.txt
STATUS NAME SOURCE
failed every account has a tier release-checks/010_tier.sql
verified paid plans are on the paid tier release-checks/010_tier.sql
failed: every account has a tier
assert: SELECT COUNT(*) = 0 FROM accounts WHERE tier IS NULL
Verdict: failed (1 verified, 1 failed, 0 errored of 2)

The run exited 1. The command exits 0 when every assertion holds, and 2 when an assertion cannot run. A path with no assertions reports not verified and exits 1, so an empty checks directory does not read as a clean release.

db verify changes nothing. Each assertion must be a single read-only SELECT, which Ptah proves from the text before sending it. Each runs in its own session, opened read-only where the engine has such a mode. The promise is that it writes nothing, not that it reads only one database. It reads whatever its login can read, so give it a login scoped to what the checks are about.

Ptah has other places for a requirement like this, and each answers a different question. ptah migrations test and ptah schema test need a throwaway database they may destroy, so they never see the database the release ran on. A postcondition, phase=after on a migration’s check, runs once after the migration commits. When it fails, nothing is rolled back and the run exits non-zero. db verify runs whenever you call it, against the database as it is. The assertions can also travel with the schema: ptah schema push --checks publishes them in a layer of their own, and db verify --checks oci://...@sha256:... reads that layer. The checks a reviewer approved are then the checks that run.

We rolled the example release back with migrations down --target 1790100000. migrations status then reported one pending migration. A database that never saw the release reports the same, and the revision table cannot tell the two apart. The migration log can:

Terminal window
ptah migrations log --db-url "$DATABASE_URL"
examples/release/measured/log.txt
STARTED VERSION OPERATION OUTCOME ACTOR SOURCE
2026-09-22T22:24:25Z 1790100060 down rolled_back release-bot provided
2026-09-22T22:24:19Z 1790100060 up applied release-bot provided
2026-09-22T22:24:16Z 1790100000 up applied release-bot provided

migrations up and migrations down append every attempt to a table beside the revision table, schema_migrations_log by default. The entries are written outside the migration’s transaction, so a failed migration still leaves its record. An attempt that never recorded an outcome reads undetermined. Each run here passed --actor release-bot, and SOURCE says provided because the name came from the caller. Without --actor or PTAH_ACTOR, Ptah records the user the process runs as.

The log has limits by design. It is not an audit trail: it lives in the database it describes, and the account that runs migrations can edit it. It is not a second source of truth, because migrations status still decides where the database stands. Nothing prunes it. migration.log: false in ptah.yaml keeps the table out, and an Atlas-format revision table keeps no log at all.

Most of the remaining changes close a path where Ptah reported success over a history that did not match the database. Each of those turns a run that used to succeed into a refusal for somebody, which is why this list is long. The new capabilities are opt-in; the changes below apply without a configuration change.

  • A new table appears. migrations up and down create the log table, schema_migrations_log by default, on the first run that records an attempt. Set migration.log: false to keep it out. If the account cannot create it, the run warns and continues.
  • An edited applied migration fails every migrations up and down with exit 2, including a run with nothing to do. Put back the bytes that ran, and write the change as a new migration.
  • An applied migration with no file fails up and down with exit 2 on the native revision format. That includes an older release deployed against a database that a newer one migrated. Run from a directory that has the file.
  • migrations status --exit-code exits 1 for both of those cases, as it already did for pending migrations.
  • migrations repair --version N refuses a revision that recorded no applied statement. Run migrations up --allow-dirty, or pass --force if you applied the migration by hand.
  • migrations up --allow-dirty refuses a revision whose first statement was interrupted. Inspect the database, then use migrations repair --version N.
  • A lock timeout is refused with exit 2 on SQLite, ClickHouse, CockroachDB, Spanner and Oracle. Remove --lock-timeout from ptah schema apply, and remove --migration-lock-timeout, PTAH_MIGRATION_LOCK_TIMEOUT or migration.migration_lock_timeout from migrations up, down and baseline against those dialects.
  • A revision table owned by another role is refused on PostgreSQL, CockroachDB, YugabyteDB and Oracle, including one an administrator created and granted to the role migrations connect as. Membership in the owning role is not enough. A low-privileged role could otherwise create the table first and attach a trigger that Ptah’s own writes would run. Move the rows you want to keep and let Ptah create the table as the migration role, or set PTAH_ALLOW_FOREIGN_METADATA_TABLE=1. ALTER TABLE ... OWNER TO also passes the check but keeps the table’s triggers, defaults and policies, so inspect the table first.
  • migrations lint --dev-url runs the MariaDB rules against MariaDB behind a mysql:// URL, and can report findings it did not report before.
  • Go embedders: importer.SourceMigration.NoTransaction is replaced by UpNoTransaction and DownNoTransaction.

At a terminal, ptah assist now runs as an inline terminal program. The prompt is edited in place, Up and Down walk the session’s questions, and an approval is a choice between allow once, allow for this session, and no. A pipe keeps the plain loop. A Go schema can mark what it does not describe with //ptah:schema:notdescribed, the Go spelling of a directive that SQL and HCL schema files already carry. ptah migrations set now works on PostgreSQL and Oracle, and at version 0. The documentation has a new Migrate from another tool group, with pages for golang-migrate, Goose, dbmate, Flyway, Liquibase and Atlas projects. Each page runs in CI on Linux, macOS and Windows.

  • At a terminal, the ptah assist approval prompt is cut at 80 columns, digests included. Only the display is affected. ptah assist | cat selects the plain loop, which prints the whole prompt.
  • ptah mcp --help says the server has six reading tools. It serves eight without --workspace and eleven with it.
  • ptah migrations data --help says the command applies no gating of its own. It refuses updates and deletes of existing rows without --allow-destructive, and changes to a --protected-table without --allow-prod.
  • ptah schema export --to markdown and --to html show a primary key as nullable when its column is not declared NOT NULL. Declare it explicitly.

The release page has the archives, the checksums and the full notes, including changes this post leaves out. The documentation links below go to the 0.8.0 version of each page.

Example files