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.
Nothing here promises zero downtime
Section titled “Nothing here promises zero downtime”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:
diff: online_alter: trueWe 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:
-- +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:
error: error running migrations: migration 1790115827 is marked no_transaction, so migration timeouts cannot be applied safelyThe 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.
Checking what the change did
Section titled “Checking what the change did”The release example adds a tier column and fills it from plan:
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:
-- +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'"ptah db verify --db-url "$DATABASE_URL" --checks release-checks/STATUS NAME SOURCEfailed every account has a tier release-checks/010_tier.sqlverified 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.
What the migration log keeps
Section titled “What the migration log keeps”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:
ptah migrations log --db-url "$DATABASE_URL"STARTED VERSION OPERATION OUTCOME ACTOR SOURCE2026-09-22T22:24:25Z 1790100060 down rolled_back release-bot provided2026-09-22T22:24:19Z 1790100060 up applied release-bot provided2026-09-22T22:24:16Z 1790100000 up applied release-bot providedmigrations 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.
Upgrading
Section titled “Upgrading”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 upanddowncreate the log table,schema_migrations_logby default, on the first run that records an attempt. Setmigration.log: falseto keep it out. If the account cannot create it, the run warns and continues. - An edited applied migration fails every
migrations upanddownwith exit2, 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
upanddownwith exit2on 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-codeexits1for both of those cases, as it already did for pending migrations.migrations repair --version Nrefuses a revision that recorded no applied statement. Runmigrations up --allow-dirty, or pass--forceif you applied the migration by hand.migrations up --allow-dirtyrefuses a revision whose first statement was interrupted. Inspect the database, then usemigrations repair --version N.- A lock timeout is refused with exit
2on SQLite, ClickHouse, CockroachDB, Spanner and Oracle. Remove--lock-timeoutfromptah schema apply, and remove--migration-lock-timeout,PTAH_MIGRATION_LOCK_TIMEOUTormigration.migration_lock_timeoutfrommigrations up,downandbaselineagainst 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 TOalso passes the check but keeps the table’s triggers, defaults and policies, so inspect the table first. migrations lint --dev-urlruns the MariaDB rules against MariaDB behind amysql://URL, and can report findings it did not report before.- Go embedders:
importer.SourceMigration.NoTransactionis replaced byUpNoTransactionandDownNoTransaction.
Also in this release
Section titled “Also in this release”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.
Known issues
Section titled “Known issues”- At a terminal, the
ptah assistapproval prompt is cut at 80 columns, digests included. Only the display is affected.ptah assist | catselects the plain loop, which prints the whole prompt. ptah mcp --helpsays the server has six reading tools. It serves eight without--workspaceand eleven with it.ptah migrations data --helpsays the command applies no gating of its own. It refuses updates and deletes of existing rows without--allow-destructive, and changes to a--protected-tablewithout--allow-prod.ptah schema export --to markdownand--to htmlshow a primary key as nullable when its column is not declaredNOT NULL. Declare it explicitly.
Where to read more
Section titled “Where to read more”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.
- Change a schema without downtime walks through the PostgreSQL lock queue and expand and contract.
- The online mode states
what
online: requireproves and what it cannot see. - Verify a release against the database
covers
ptah db verifyand checks published with a schema artifact. - The migration log describes what a log entry means and what it is not.
- Maintain migration history
covers repair,
migrations setand the metadata-table ownership check.
Example files
- commands.json
- online/blocker.sql
- online/measured/1790115827_plan_known.up.sql
- online/measured/blocker.txt
- online/measured/compare.txt
- online/measured/constraint.txt
- online/measured/generate-without-online-alter.txt
- online/measured/generate.txt
- online/measured/lint-online-mode.txt
- online/measured/lint.txt
- online/measured/lock-timeout-refused.txt
- online/measured/log.txt
- online/measured/online-mode-up-lock-timeout.stderr.txt
- online/measured/online-mode-up-lock-timeout.txt
- online/measured/online-mode-up-tx-none.stderr.txt
- online/measured/online-mode-up-tx-none.txt
- online/measured/online-mode-up.stderr.txt
- online/measured/reader-before.txt
- online/measured/reader-during.stderr.txt
- online/measured/reader-during.txt
- online/measured/status.txt
- online/measured/up-behind-blocker.stderr.txt
- online/measured/up-behind-blocker.txt
- online/measured/up-lock-timeout.stderr.txt
- online/measured/up-lock-timeout.txt
- online/measured/waiting.txt
- online/measured/without-online-alter.up.sql
- online/migrations/1790100000_create_accounts.down.sql
- online/migrations/1790100000_create_accounts.up.sql
- online/migrations/1790115827_plan_known.down.sql
- online/migrations/1790115827_plan_known.up.sql
- online/migrations/ptah.sum
- online/online-lint.yaml
- online/ptah.yaml
- online/reader.sql
- online/schema.sql
- online/waiting.sql
- README.md
- release/existing-accounts.sql
- release/measured/compare.txt
- release/measured/down.stderr.txt
- release/measured/down.txt
- release/measured/existing-rows.txt
- release/measured/log.json
- release/measured/log.txt
- release/measured/sqlite/compare.txt
- release/measured/sqlite/log.txt
- release/measured/sqlite/verify.txt
- release/measured/status-after-down.txt
- release/measured/status.txt
- release/measured/up-create.stderr.txt
- release/measured/up-create.txt
- release/measured/up-release.stderr.txt
- release/measured/up-release.txt
- release/measured/verify-empty.txt
- release/measured/verify.json
- release/measured/verify.txt
- release/migrations/1790100000_create_accounts.down.sql
- release/migrations/1790100000_create_accounts.up.sql
- release/migrations/1790100060_add_tier.down.sql
- release/migrations/1790100060_add_tier.up.sql
- release/migrations/ptah.sum
- release/release-checks/010_tier.sql
- release/schema.sql
- verified.json