Apply migrations
Run pending migrations with integrity verification, inspect status, wire operational hooks, and apply from OCI artifacts.
You have a hashed migration directory and a database that needs to catch up. Preview the apply, run it with integrity verification, read the resulting state in scripts and CI, and add the operational controls the target needs.
Prerequisites
Section titled “Prerequisites”A migration directory sealed with ptah migrations hash (see
Generate migrations). The examples use a local SQLite file;
substitute your own --db-url.
If you do not have one, build it here. Start in an empty directory:
mkdir ptah-applycd ptah-applySave the pair as migrations/1785255952_init.up.sql:
CREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT NOT NULL);and migrations/1785255952_init.down.sql:
DROP TABLE users;Seal the directory:
ptah migrations hash --dir ./migrationsExpected output on standard output:
2 migration file(s) hashedPreview with a dry run
Section titled “Preview with a dry run”On a fresh target, --dry-run prints every statement that would execute,
without touching the database:
ptah migrations up --db-url "sqlite://app.db" --migrations-dir ./migrations --dry-runExpected output on standard output:
=== DRY RUN MODE ===No actual changes will be made to the database
✅ Dry run completed successfully!Would have applied 1 migrationsThe statement-by-statement narration is a log record, not report output, so it goes to the log stream — stderr by default:
level=INFO msg="[DRY RUN] Would begin transaction"level=INFO msg="[DRY RUN] Would execute SQL" sql="CREATE TABLE \"users\" ..."level=INFO msg="[DRY RUN] Would commit transaction"Both --log-level and --log-format apply to it: --log-level warn drops the
narration and leaves only the stdout report, and --log-format json moves every
record — report lines included — onto stdout as one JSON object per line, which
keeps ptah migrations up ... | jq parseable. See
Execution controls.
Apply with integrity verification
Section titled “Apply with integrity verification”ptah migrations up --db-url "sqlite://app.db" --migrations-dir ./migrations --verify-sumExpected output on standard output:
=== MIGRATE UP ===Dialect: sqliteTransaction mode: file
Current version: 0Total migrations: 1Pending migrations: 1
✅ Migrations completed successfully!Database is now at version: 1785255952A hashed directory (one that carries ptah.sum or atlas.sum) is always
verified before anything runs, and the apply aborts on drift — the same check
ptah migrations validate performs. A directory without a sum file is not
gated on this native surface. --verify-sum additionally makes a missing sum
file itself an error, so set it for shared environments where the directory
must be hashed. ptah-compat migrate apply refuses a never-hashed Atlas
directory outright, because Atlas does; see
Integrity and safety.
--verify-sum is registered on migrations up, down, status and push.
The requirement is the same on each — carry a sum, and match it — but on the
first three the subject is the directory the run pulled, while on push it is
the local directory about to be published.
Either check compares the directory against the sum stored beside it, so what it establishes depends on where the directory came from. See Apply from an OCI registry for what that means when the directory is a registry artifact.
Applied versions land in the revision table. Rerunning the same command is a safe no-op:
ptah migrations up --db-url "sqlite://app.db" --migrations-dir ./migrations --verify-sumExpected output on standard output:
✅ Database is already up to date!Locking and --migration-lock-timeout
Section titled “Locking and --migration-lock-timeout”A session advisory lock keeps two runners from advancing one migration history
at the same time, and --migration-lock-timeout bounds how long a run waits for
it. Not every engine gives Ptah such a lock, and a run against one that does not
proceeds unlocked.
A lock timeout aimed at a target that cannot lock is refused. The message itself names the engines that do lock, so no copy of that list lives here:
ptah migrations up --db-url "sqlite://app.db" --migrations-dir ./migrations --migration-lock-timeout 10sExpected output on standard error:
error: --migration-lock-timeout requested the migration advisory lock, and dialect "sqlite" has none: only postgres, yugabytedb, mysql, mariadb, sqlserver take a session advisory lock. Remove --migration-lock-timeout to run without a lockNothing is applied and the revision table is untouched. A URL that names its
dialect is refused before the connection opens, which is why a sqlite://
target that does not exist yet is left uncreated. A PostgreSQL-wire URL names no
product: postgres:// can reach CockroachDB or Spanner, and that target is
decided once the server has named itself — after the connection, still before
the first migration runs. Remove the flag to run unlocked, which is what such a
target does anyway.
ptah migrations down and ptah migrations baseline answer the same way: all
three take the same lock on the same history.
Every spelling refuses. PTAH_MIGRATION_LOCK_TIMEOUT fills the flag on each of
those commands, and migration.migration_lock_timeout in
the project config fills it on up and
down; all of them name this one lock, so a value arriving from any of them
asks for a lock the target cannot give. The message says which spelling carried
the value.
ptah migrations checkpoint registers the same flag for a different lock: the
one its replay takes on the shadow database, not on a target. A checkpoint
writes files rather than advancing a history, so a shadow database that cannot
lock earns a note on standard error and the replay goes ahead.
ptah-compat migrate down --lock-timeout reaches the same command and is not
refused. That surface answers to the Atlas contract, and what the Atlas CLI does
with a lock timeout on a dialect that cannot lock is not measured here.
Check status
Section titled “Check status”ptah migrations status --db-url "sqlite://app.db" --migrations-dir ./migrationsExpected output on standard output:
Current Version: 1785255952Total Migrations: 1Applied Migrations: 1Pending Migrations: 0Out-of-order Migrations: 0Status: ✅ Database is up to dateFor scripts, --json prints the same state as one object:
ptah migrations status --db-url "sqlite://app.db" --migrations-dir ./migrations --json{ "current_version": 1785255952, "applied_migrations": [ 1785255952 ], "applied_migration_keys": [ "1785255952" ], "pending_migrations": [], "out_of_order_migrations": [], "total_migrations": 1, "has_pending_changes": false, "current_version_key": "1785255952", "contract_version": 1, "migrations": [ { "version": 1785255952, "version_key": "1785255952", "description": "Create users", "checksum": "h1:Ow4bDq9rLuGUoQxXSbCJn/y4Q1oZbLvBg9wEQ8B4V0M=", "applied_checksum": "h1:Ow4bDq9rLuGUoQxXSbCJn/y4Q1oZbLvBg9wEQ8B4V0M=", "state": "applied" } ]}contract_version is the version of the document, not of Ptah. A consumer that
does not know the version it reads should refuse the document rather than read
the fields it recognizes out of a shape that means something else. A new field
an existing reader can ignore does not raise it.
The migrations array is what a caller plans from. Each entry carries the
migration’s version and exact revision identity, its description, the checksum
of the file as it stands, the checksum the revision row recorded, whether the
file is a checkpoint, the transaction mode the file declares resolved for this
dialect, and one state:
| State | Meaning |
|---|---|
applied |
A clean revision row whose file still accounts for it |
modified |
A clean revision row whose file no longer accounts for it; nothing may apply while one exists |
dirty |
The version a failed or interrupted run left behind |
pending |
Not applied |
out-of-order |
Pending, below the current version |
checkpoint-covered |
Below the checkpoint that covers it, so it will never run here |
A seventh state, missing, never appears in that array: it belongs to a
revision the database recorded and the directory holds no file for, and there is
no entry to carry it. Those are listed in missing_migrations, in recorded
order, each with an empty checksum because there is nothing to hash.
checkpoint_version names that checkpoint: the one a fresh database
bootstraps from, or the newest one the database has applied. A migration below
it reads as covered on both sides of the bootstrap, because applying the
checkpoint is what the bootstrap does.
Comparing checksum against applied_checksum is not the rule that decides
modified, and a caller must not reimplement it: an Atlas history records a
running hash over every preceding file, so the two strings differ for reasons
that are not an edit. state is that rule’s own answer.
Set --exit-code in CI when a database that is not up to date should fail the
job: the command exits 1 while pending work exists, while any migration is
modified, and while missing_migrations is non-empty. Those last two are
states a deployment must not run into. It exits 0 once none holds.
ptah migrations up and ptah migrations down refuse in both of them,
including the run that has nothing else to do, and refuse before anything is
applied.
A modified migration stays refused until the file carries the bytes the
revision row recorded, so the recovery has an order: put those bytes back, then
carry the change you wanted in a new migration. A new migration on its own
cannot run, because the refusal precedes it.
A missing one has no file to compare, so nothing can say what it did or roll it back. Two situations reach it and they read the same to the database: a file removed from the directory, where restoring it is the way back, and an older release running in front of a database a newer one migrated, where the release holding the file is. Ptah does not guess which.
Which of the two refuses depends on the revision format. A native history
refuses, because nothing writes a row there without a file. An Atlas-format
history does not: it is a history the Atlas community binary also writes, that
binary applies such a directory rather than refusing it, and retiring a
migration file while its recorded history stays readable is something Ptah
supports on purpose for converted directories. So on an Atlas history the state
is reported and the apply proceeds. missing_migrations and the
migrations status report carry it either way, and --exit-code treats it as
not up to date either way; only the apply is scoped.
Adoption is not scoped, whatever the format. ptah project adopt --check --preflight refuses a history whose directory cannot account for it, because
after a takeover the history is native and a row with no file has nothing to be
read from.
The evidence a run leaves
Section titled “The evidence a run leaves”ptah migrations up --json prints one document describing the run, on the
successful path and on the failing one. Under this flag the document is standard
output and everything written for a person goes to standard error, so a caller
parses one stream and reads the other.
{ "contract_version": 1, "direction": "up", "outcome": "failed", "planned": [1785255952, 1785255999], "applied": [1785255952], "error": "failed to apply migration 1785255999: ...", "status": { "…": "the history after the run" }}The outcome is read from the revision table rather than from the exit status, because a run that died between its last statement and its answer exits the same way as one that never started:
| Outcome | Meaning |
|---|---|
up-to-date |
Nothing was pending, so nothing was selected |
applied |
Every selected migration is recorded applied, and no row is dirty |
dry-run |
The run was asked to change nothing, and did not |
failed |
The run stopped, and the migration that failed committed nothing: its dirty row counts no applied statements |
partial |
The migration that failed committed some of its statements and not the rest |
unknown |
The evidence could not be read, or a dirty row does not say how far it got |
failed and partial are different instructions. A failed run may be retried
once the cause is fixed. A partial run must not: the recovery is
ptah migrations repair --resume-from, which has the row’s partial digest to
prove which statements it may skip. unknown is for a person.
planned is what the migrator selected while holding the migration lock, which
a --limit, a target version or a checkpoint narrows; applied names the
selected migrations the history records afterwards, so a migration that was
already applied before the run is not counted as this run’s work.
Execution controls
Section titled “Execution controls”The defaults are safe for most runs. These controls matter once several people and pipelines share a directory:
-
Transaction mode (
--tx-mode):file(default) wraps each migration in its own transaction;allruns the selected batch in one;nonedisables wrapping. A migration may selectfileornonewith a leading-- atlas:txmode <mode>header, or with-- +ptah no_transaction. A blank line after that header is accepted but not required. Explicit file modes conflict with globalall.Ptah transaction, timeout, and online-DDL directives are significant only before the first executable statement. Atlas transaction mode uses its own stricter header rule described below. A directive written below the statements it claims to govern is not honored — and it is not dropped in silence either. Ptah reports it at
WARNon stderr, naming the file, the line, the directive and how to fix it, because an operator who writestxmode none, sees exit0, and believes the file ran outside a transaction is the failure this rule exists to prevent. The-- atlas:spelling keeps Atlas’s own stricter acceptance inside that region: only the unbroken run of line comments that begins on line 1, each starting in column 1. A leading blank line, an indented directive, or a blank line between the directive and an earlier comment all put it outside that block, so Atlas CE ignores it and so does Ptah — with the same warning.-- +ptahdirectives accept the whole region, blank lines and indentation included.The region uses the target database’s comment grammar, read from the same lexer options Ptah splits the file’s statements with — never from a separate list of dialects, which is how the two would drift apart. A leading
#comment therefore stays part of the header wherever that reader treats it as a comment, which is every supported target except SQL Server, and does not hide theno_transaction,lock_timeoutorstatement_timeoutdirectives that follow it. On SQL Server#is not a comment, so a directive below one really does sit below the first non-comment line and Ptah reports it.The same grammar decides where a
--comment begins. MySQL and MariaDB start one only when a whitespace or control character follows the second dash, so a--separator line stays in the header — including its-- \r\nform and a--line carrying nothing but its line terminator — while--x, which those two read as SQL rather than as a comment, ends the header there.Before a connection exists the dialect is unresolved, and there Ptah reads the header the widest way any target would. That direction is deliberate: loading a file happens first and its verdict is final, so a header cut short there would refuse a correctly placed directive as being below the first SQL statement and offer a remedy — move it up — that the line already satisfies. Ptah then resolves the effective timeouts and transaction mode with the execution dialect before it validates or runs the migration.
Position and value are separate facts, and a bad value is not demoted to a position warning. A
-- +ptahdirective whose key Ptah recognizes but whose value it cannot read —no_transaction=maybe,lock_timeout=soon— fails the run wherever the line sits, and the refusal names the position too, so you are not told the value is nonsense, told nothing about the line being in the wrong place, and left to discover that on the next run. A timeout key written with no value at all —-- +ptah lock_timeout— fails the same way, and it does so whatever else shares the line: a neighboring field Ptah can read says nothing about whether the timeout has a value. A bare word Ptah does not recognize as a directive (-- +ptah revisit this) is an ordinary comment and is neither refused nor reported, and neither is a word inside an ordered-- +ptah checkline, whose quoted arguments are that directive’s own grammar rather thankey=valuefields.The
-- atlas:spelling deliberately has no equivalent refusal. Measured on Atlas CEv1.3.0,migrate applyover a directory carrying-- atlas:txmode bogusexits1when the line is the header and0when it sits below the statement. Refusing the second would exit non-zero where Atlas CE exits0, so Ptah reports it and applies the directory, as CE does. -
Batch limit (
--limit): apply only the first N pending migrations — useful for staged rollouts and verifying one step at a time.--allow-dirtyis the explicit recovery escape hatch that proceeds past a dirty revision row. When the dirty row belongs to a migration that is still pending — the usual case, a body that failed part-way — the retry reuses that row rather than recording a second one, and skips the statements the earlier attempt committed. Before it skips anything, Ptah verifies that the committed source prefix is unchanged. Native rows carry apartial:h1:prefix checksum; Atlas-format rows carry cumulativepartial_hashes. Editing only the unapplied suffix is allowed. A failed retry cannot reduceappliedbelow the previously committed prefix, even when the transaction mode changed. Everynoneattempt runs its migration SQL on one pinned physical database session, so a statement such asSET search_pathremains effective for the statements that follow. On server databases, revision checkpoints remain on the original connection and cannot be redirected by that session state. SQLite uses the pinned session but qualifies its revision table inmain, so in-memory databases do not deadlock and temporary objects cannot shadow the metadata. A resumed attempt uses a fresh session: Ptah replays recognized session-control statements from the verified prefix, skips recognized durable DDL and DML, and refuses prefixes that created temporary objects or whose session-local effect cannot be classified safely. Anonebody cannot contain top-level transaction-control statements such asBEGIN,COMMIT,ROLLBACK, savepoint commands, MySQL or MariaDBSET autocommit, or SQL ServerSET IMPLICIT_TRANSACTIONS. Ptah validates the complete body and pins its physical session before changing revision metadata, so either failure leaves a new version absent and preserves an existing dirty row unchanged. MySQL and MariaDB also reject transaction-control statements infilemode: Ptah owns that file transaction, and changingautocommitinside the body would make a post-DDL checkpoint ambiguous. Their revision metadata, default storage engine, and every existing base table in the selected database must use InnoDB. A migration that explicitly selects another storage engine, resets an engine setting toDEFAULT, or inherits one throughCREATE TABLE ... LIKEis refused before its first statement.DEFAULTcan resolve differently from the session default that preflight verified. On MySQL, the migration account must holdTRIGGERat database or global scope so Ptah can see a complete trigger catalog. MariaDB exposes each trigger’s identity and target table withoutTRIGGER, so it does not require that privilege for this catalog check. Ptah refuses MySQLfilemode when it cannot prove complete visibility. The MySQL grant must be global or must name the selected database exactly inSHOW GRANTS. Ptah decodes escaped literal wildcard characters in the database name, but deliberately does not infer coverage from an unescaped%or_pattern because MySQL’spartial_revokessetting changes that pattern’s meaning. Everysql_modeassignment is also refused because changing grammar or quoting rules after preflight can make the server execute SQL that Ptah did not inspect. A session that already enables parser-changingANSI_QUOTES,MSSQL, orNO_BACKSLASH_ESCAPESbehavior through the connection DSN or server default is refused for the same reason. Durable server-state operations such asSET GLOBAL,SET PERSIST,RESET, andCREATE,ALTER, orDROP DATABASEare refused for the same reason: their effects do not share the InnoDB transaction containing the witness.SELECTorTABLEwithINTO OUTFILEorINTO DUMPFILEis also refused because it writes outside that transaction. The equivalentSCHEMAstatements andUSEare rejected as well; select the target database in--db-urlso Ptah can validate the database it will modify. A statement that names another database is inspected rather than refused: the engine preflight and the object catalog cover every database a migration names. Ptah also refuses executable comments,CALL, prepared or dynamic SQL, table locks, definitions of views, triggers, routines, and events, references to existing views or trigger-bearing tables, and calls to stored routines. Those forms can hide work that does not share the witness transaction. A customMigrationFuncis opaque for the same reason and must usenone; aStatementInterceptoris also opaque because it can replace the inspected statement with different SQL. MySQL-familyfilemode accepts directly executed SQL-backed migrations only. Statement-level rejection diagnostics identify the statement number and safety class without echoing the SQL, which may contain credentials. Migration-function and interceptor refusals identify the affected direction instead because their inner statements are opaque. The migration advisory lock serializes Ptah clients that use the same lock name. It cannot freeze DDL from a client that ignores that lock. Do not run out-of-band DDL from the safety preflight until the migration finishes. Pre-migration checks are not rerun after committed progress because they describe the original pre-migration state. Automatic continuation is up-direction only: a row left dirty by an interrupted rollback is refused so up SQL cannot be resumed from a down-statement offset. Useptah migrations repairwhen the row cannot be resumed automatically: an interrupted rollback, a process whose last statement has an unknown outcome, changed or unverifiable committed-prefix metadata, an edit that changed the file’s statement count, or a dirty row for a migration whose file was rebased away. Legacy dirty rows without prefix metadata may resume only while their full-file checksum still matches. -
Version bound (
--to-version): apply pending migrations up to and including one exact version, and stop there. An approved plan names the versions it covers before the run starts, and this is how the run is held to them. It is equally how you take half a backlog deliberately and watch what happens.Terminal window ptah migrations up \--db-url "$DATABASE_URL" \--migrations-dir migrations \--to-version 1785255952The version is written the way the migration file writes it, so leading zeros are accepted. A version the directory does not carry is refused, and so is a version the database has already passed: nothing pending reaches the target there, and an exit
0would report a state the database never arrived at. A database recorded at exactly the named version is not that case, and applies nothing and succeeds. Under--exec-order linear-skipa target the execution order leaves pending is refused as well, naming the order rather than the recorded version, because--exec-order non-linearis what reaches it.Passing
--limitas well is refused: the two select different prefixes of the pending list and neither outranks the other. The refusal reads the values, not the spelling, soPTAH_TO_VERSIONbesidePTAH_LIMITis refused before the run connects, while--to-version ""and--limit 0ask for no bound and leave the other flag to decide. A flag typed beside the other’s variable wins, and the variable is withdrawn.The bound narrows what
--dry-runreports and what--jsonnames underplanned, because both read the plan the migrator selected while holding the migration lock rather than the pending list read before it. -
Execution order (
--exec-order):linear(default) fails when a merge landed a pending migration below the current version;linear-skipwarns and leaves it pending;non-linearapplies it. Status reports such versions as out-of-order. -
Timeouts and locks:
--statement-timeout,--lock-timeout, and--migration-lock-timeoutbound long DDL and the session-level advisory lock that keeps two migrators from racing. A target whose dialect has no such advisory lock refuses--migration-lock-timeout; see Locking and--migration-lock-timeout. A migration that resolves tononecannot use statement or lock timeouts. Ptah rejects the combination before executing SQL or changing the revision row. A file-levelfileoverride under globalnonerestores the transaction and may use timeouts. SQL-backed non-transactional migrations record a durable progress marker before and after each autocommit statement. A custom GoMigrationFuncremains opaque and is recorded only when it returns.
What --tx-mode all cannot carry
Section titled “What --tx-mode all cannot carry”--tx-mode all runs every selected migration in one transaction. Three other
features are scoped to a single migration, so they do not compose with it, and
each is refused before any SQL runs rather than discovered one migration at a
time.
Combined with --tx-mode all |
Result |
|---|---|
| A target with no transactional DDL | refused: tx-mode all is not supported for dialect "…": this target commits schema changes as they run, so a failed migration cannot be rolled back as a unit |
| A migration declaring pre-migration checks | refused: migration N declares pre-migration checks, which cannot run with tx-mode all |
| A migration declaring timeouts | refused: migration N declares timeouts, which cannot run with tx-mode all |
| A migration selecting its own transaction mode | refused: an explicit file-level file or none conflicts with global all |
The reason is the same in the middle two cases: one transaction spans the whole run, so a per-migration timeout would bound the entire batch rather than the file that asked for it, and a pre-migration check would read state the batch has already changed. The remedy is the default per-file mode, or removing the directive from that migration.
The first row is the engine rather than a Ptah policy, and it is decided by the target’s transactional-DDL capability rather than by a list of dialect names. MySQL, MariaDB, ClickHouse, Oracle and Spanner commit DDL as it runs. CockroachDB refuses for a narrower reason: a target named with no server version in hand resolves to the newest measured line, where a schema statement inside a transaction commits itself first, so the rollback has nothing left to undo. A connected CockroachDB server on an older line reaches the capability through the version ladder and is accepted.
Timeouts themselves are not tied to that capability and reach every target whose
server takes a session or transaction timeout; a target that takes none refuses
--lock-timeout and --statement-timeout by naming the engine.
- Run logging (
--log-level,--log-format):--log-leveldebug|info|warn|error selects how much of the run is narrated —warnsilences the per-statement dry-run narration — and--log-formattext|json selects the encoding.jsonmoves every record, the stdout report included, onto stdout as one JSON object per line.
See Configuration for the ptah.yaml
equivalents of every control.
--allow-dirty means two different things
Section titled “--allow-dirty means two different things”One spelling, two surfaces, two unrelated safety questions. The collision is permanent — the compatibility surface registers the flag Atlas registers, and the native surface registers the one Ptah has always had — so read the flag against the command it was typed after:
| command | what is dirty | what the flag asks for |
|---|---|---|
ptah migrations up |
a revision row, left by a migration body that failed part-way | a verified retry of that body, skipping only an unchanged committed prefix |
ptah-compat migrate apply |
the schema, which already holds objects this history did not create | adopt that database anyway and apply into it |
Neither can be expressed in terms of the other, and neither implies the other.
Measured against the pinned community binary, its --allow-dirty releases no
dirty-revision guard at all: an operator who passes it there has said nothing
about revision rows, and one who passes it to ptah migrations up has said
nothing about adopting a populated database.
Native ptah migrations up has no adoption gate, so nothing refuses a database
it did not create; running it against one fails on the first object that
already exists. ptah migrations baseline is the native way to adopt an
existing database, and --shadow-db verifies that the baselined history
reproduces the schema it was pointed at.
Operational hooks
Section titled “Operational hooks”Production-like runs should be configured, not wrapped in ad hoc shell
scripts. ptah migrations up supports:
--pre-up-hook— a shell command that must exit0before anything is applied (rollback runs have--pre-down-hook).--webhook— a URL that receives migration metadata and must return HTTP 200 before the run proceeds.--pg-dump-to/--mysqldump-to— a directory where a backup is written before migrations are applied.- Revision-table placement (
--migrations-table,--migrations-schema) and Prometheus metrics (--metrics-addr). --migrations-engine— the storage engine the revision table is created with. It exists for ClickHouse, where a replicated deployment needsReplicatedMergeTree(...)or the migration history lives on one node while every replica reports itself consistent; see the ClickHouse revision table’s storage engine. An engine the revision table cannot be is refused before any statement runs.
All of these can live in ptah.yaml instead of the command line; see
Configuration.
Apply from an OCI registry
Section titled “Apply from an OCI registry”up, status, and down accept an oci:// reference as the migrations
directory, so the artifact your CI published is exactly what production runs
— pin it by immutable digest:
ptah migrations push \ oci://ghcr.io/acme/app-migrations \ --migrations-dir ./migrations \ --verify-sum
ptah migrations up \ --db-url "$DATABASE_URL" \ --migrations-dir oci://ghcr.io/acme/app-migrations@sha256:<digest> \ --verify-sumPin the digest deliberately: --verify-sum checks the pulled directory against
the sum shipped inside the same artifact, so over a movable tag it proves the
files are internally consistent, not that they are the reviewed ones. up
prints that qualification, along with the digest the tag resolved to and the
reference that pins it, whenever a sum verifies over a tag-resolved artifact.
The run still succeeds; a digest reference gets no such line.
To keep the readable name and the pin together, write both:
ptah migrations up \ --db-url "$DATABASE_URL" \ --migrations-dir oci://ghcr.io/acme/app-migrations:release@sha256:<digest> \ --verify-sumThe digest selects the bytes and is verified against what the registry returns;
the tag is a label. Repointing :release afterwards changes nothing about what
this command runs, and this reference counts as a digest pin, so it gets no
movable-tag qualification.
See OCI registry artifacts for authentication, tag and digest semantics, referrer reports, and CI wiring.
Failure modes
Section titled “Failure modes”Integrity drift aborts before anything runs (exit 2):
error: migration sum verification failed:migration directory does not match ptah.sum: changed: 0000000002_add_posts.up.sqlDestructive pending migrations are refused by default (exit 2); rerun
with --allow-destructive after review:
error: error running migrations: pending migrations contain destructive statements; rerun with --allow-destructive after review:- 0000000003_drop_users.up.sql:1 DS101 error: DROP TABLE permanently deletes table users and every row in it; ...The integrity gate is covered in depth on Integrity and safety, the destructive gate on Lint and gate unsafe SQL.
A migration failed partway. The revision table records a dirty state and
every later run refuses to continue until it is repaired — see
Maintain migration history. A failed rollback records
the same recoverable state in both revision-table formats;
ptah-compat migrate down keeps the Atlas table layout but does not copy
Atlas’s hidden failed-down state. Roll back migrations shows
how to resume it through the native repair command.
What a failed body records depends on the transaction mode — and, on the MySQL family, on the statements themselves. The committed prefix a resume skips is only ever the prefix that really survived:
- Under
none, every statement commits on its own. The revision row is checkpointed after each one, soappliedand the cumulative digests name exactly the statements that ran, and the retry continues at the next one. - Under
fileorallon PostgreSQL, CockroachDB, YugabyteDB, SQLite and SQL Server, DDL is transactional and the failure rolls the whole body back. Nothing is recorded as committed, and the retry runs the body from its first statement. An Atlas-format row written for such a failure is removed rather than left claiming progress that no longer exists. - Under
fileon Oracle, Ptah opens no transaction around the body. Oracle commits before every schema statement, so each statement commits as it runs, as undernone, andappliedcounts the statements that ran before the one that failed. Oracle does not supportall. - Under
fileon Spanner, Ptah opens no transaction around the body. Spanner’s PostgreSQL interface refuses a schema statement inside an explicit transaction, so the migrator applies the body unwrapped and each statement commits as it runs, as undernone.appliedcounts the statements that ran before the one that failed, and those statements stay in the database. Spanner does not supportall. - Under
fileon MySQL and MariaDB, the server may commit the open transaction around DDL, so part of a failed body can survive its final rollback. Ptah does not infer that prefix from SQL keywords. Before and after each statement it updates the InnoDB revision row on the same physical transaction as the body. A server-side implicit commit therefore makes the matching witness durable; an ordinary rollback removes both user DML and its witness. Plain DML that rolls back retries from the first statement, while a durable DDL/DML prefix resumes at the first statement not witnessed as complete. Ptah pins and then discards the physical session; a retry replays safe session settings from the verified prefix before it continues. Temporary tables remain available: their DDL does not make permanent InnoDB work durable by itself, and discarding the session prevents temporary state from leaking into a retry. If a later implicit commit makes a prefix containing a temporary object durable, automatic resume refuses because that object cannot be reconstructed safely. The configured migration metadata table name is reserved. Before reading or writing revision metadata, Ptah refuses and discards a pinned session that already contains a same-named temporary table. It also rejects statements that directly reference the metadata relation, including through a schema-qualified name. If a witness committed before a failing statement whose lack of side effects cannot be proven, the row remains marked unknown and automatic retry stops for manual inspection. MySQL and MariaDB do not supportall.
Recorded progress therefore excludes transactional work that a rollback undid, while non-transactional mode retains work that no rollback could undo. On MySQL and MariaDB, non-transactional mode keeps revision bookkeeping on a separate checked metadata session while the migration body runs on its own discarded session, so body-local temporary objects cannot shadow the metadata table.
A non-transactional statement was interrupted. If the process exits, the
context is canceled, or its deadline expires while an autocommit statement is
in flight, the revision row preserves the last known completed statement and
marks the interrupted statement’s outcome as unknown. Inspect the database
before repair. Both repair --resume-from and up --allow-dirty refuse the
row while this marker is present, because the SQL may already have committed
and neither verb can tell. The refusal holds when the marker sits on the first
statement, where the row records no completed statement at all: zero says no
checkpoint was written, not that nothing ran.
A concurrent index build failed on PostgreSQL (exit 2). The invalid index
left behind keeps the name, so re-issuing the generated IF NOT EXISTS
statement is skipped rather than retried and reports no error. Ptah refuses to
run the migration while an index a conditional create expects is unusable, and
names the REINDEX INDEX CONCURRENTLY that rebuilds it:
error: error running migrations: migration 1785756328 cannot be applied: PostgreSQL reports index "public"."idx_members_email" (indisvalid=false, indisready=false) unusable, and CREATE INDEX ... IF NOT EXISTS finds the name taken and skips it rather than rebuilding it, so this run would record the migration applied over a constraint that is not enforced; run REINDEX INDEX CONCURRENTLY "public"."idx_members_email", or drop the index, then run the migration again--allow-dirty does not bypass this — retrying the body is what the refusal is
about. An invalid unique index enforces nothing, so without the refusal the
run would exit 0, report the database up to date, and keep accepting duplicate
rows. Rebuild the index with the REINDEX the message names, or drop it so the
name is free and the statement builds it, then run again. Only indexes named by
CREATE INDEX ... IF NOT EXISTS are checked, and only on PostgreSQL; ordinary
creates retain PostgreSQL’s normal error semantics and may be renamed or removed
by later statements. Other dialects
have no concurrent index build to leave half-finished. A dry run is exempt,
because it records nothing. ptah migrations repair refuses on the same
grounds — see Maintain migration history.
An intentional DROP INDEX followed by a matching create is allowed when the
drop will execute in the current attempt. A statement skipped by dirty-resume
cannot serve as that cleanup. Ptah resolves both unqualified drops and target
tables through search_path, then checks the schema-level relation name. An
index on another table or a non-index relation with that name is a conflict.
After the body, Ptah positively verifies on the active transaction or connection
that every conditional create’s statement-local schema, target table, and index
name still describe a usable result. Equal raw names under different
search_path values remain distinct checks.
Repair that cannot reconstruct an explicit original path checks every same-named target in PostgreSQL user schemas, so the repair session’s current path cannot hide another candidate.
A partitioned parent index created with CREATE INDEX ... ON ONLY is accepted
in its expected ready-but-incomplete catalog state.
A pre-migration check blocked the migration. Nothing is applied and no
revision row is written, so the run is recorded as never started. Fix the data
the check guarded and re-run; no repair step and no bypass flag is involved.
ptah migrations up --skip-checks exists as an emergency override, and the
Atlas-compatible ptah-compat migrate apply has no such flag, matching Atlas.
Next steps
Section titled “Next steps”- Need to undo an applied migration? Roll back migrations.
- Hardening the pipeline that runs this command? Integrity and safety.
- Fresh databases replaying years of history? Checkpoints.