Atlas migrate commands
You have an Atlas-format migration directory — or scripts that manage one with
atlas migrate ... — and want to run that workflow through Ptah. This page
covers the ptah-compat migrate verbs: what each one does, a worked example,
and the behavior details that differ from a first guess. Every invocation on
this page uses the separate ptah-compat drop-in binary; the install steps
plus the flag translation rules are on the
Atlas compatibility overview.
Command behavior
Section titled “Command behavior”| Atlas-compatible command | Ptah behavior |
|---|---|
ptah-compat migrate apply |
Atlas-format apply path equivalent to ptah migrations up; executes every Atlas OSS directory format and refuses an Atlas directory whose atlas.sum is missing or stale. |
ptah-compat migrate down |
Forwards to ptah migrations down with mapped Atlas flags and the Atlas revision-table layout by default; --dev-url verifies the rollback plan first. Failed rollbacks retain Ptah’s recoverable dirty state. |
ptah-compat migrate status |
Atlas-format migration status with Atlas revision-table metadata; refuses an Atlas directory whose atlas.sum is missing or stale. |
ptah-compat migrate hash |
Forwards to ptah migrations hash; writes atlas.sum by default. |
ptah-compat migrate validate |
Silently verifies atlas.sum on success; --dev-url replays migrations to validate SQL execution. |
ptah-compat migrate lint |
Forwards to ptah migrations lint with Atlas changeset selectors, dev-database replay, and Atlas report output. |
ptah-compat migrate new |
Creates an Atlas single-file skeleton migration; equivalent to ptah migrations create. |
ptah-compat migrate set [version] |
Moves Atlas revision history to the selected version without executing migration SQL; refuses an Atlas directory whose atlas.sum is missing or stale. |
ptah-compat migrate diff |
Replays local migrations on --dev-url, diffs them against the desired state, and writes the selected layout with atlas.sum updated atomically. |
ptah-compat migrate import |
Imports local file:// migration directories from Atlas-supported formats into a separate Atlas single-file directory. |
ptah-compat migrate checkpoint [name] |
Forwards to ptah migrations checkpoint; writes a cumulative-schema checkpoint, Atlas single-file format by default or the ptah pair under --dir-format ptah. |
ptah-compat migrate test [paths] |
Forwards to ptah migrations test with Ptah-native YAML test cases. |
ptah-compat migrate edit {name | version} |
Forwards to ptah migrations edit and rewrites the directory checksum. |
ptah-compat migrate rebase {name | version} |
Forwards to ptah migrations rebase; one migration per run. |
ptah-compat migrate rm {name | version} |
Forwards to ptah migrations rm and rewrites the directory checksum. |
ptah-compat migrate ls |
Forwards to ptah migrations ls; lists the directory’s migration files with -s/--short and -l/--latest, and refuses an Atlas directory whose atlas.sum is missing or stale. |
ptah-compat migrate show {name | version}... |
Forwards to ptah migrations show; prints each named migration’s SQL, and refuses an Atlas directory whose atlas.sum is missing or stale. |
ptah-compat migrate push |
Atlas CE boundary stub; the native ptah migrations push to any OCI registry is the open replacement. |
Per-verb status detail — Atlas differences, waivers, and the inputs that fail explicitly — is on Atlas-compatible commands.
The --dir default
Section titled “The --dir default”Almost every migrate verb registering --dir defaults it to
file://migrations, so ptah-compat migrate apply --url "$DATABASE_URL" run
from a project root reads ./migrations with no flag at all. That is apply,
diff, edit, hash, lint, ls, new, rebase, rm, set, show,
status, test and validate, and it includes the eight verbs Atlas
documents the default on, with the same value
(#1241).
Two verbs are outside it, and the difference is worth knowing before a run:
migrate checkpoint reaches ./migrations without the flag carrying a default,
and migrate down refuses a run with no directory at all —
migrations directory is required — because rolling back against a directory
nobody named is a destructive guess.
The default is a default, not a fallback. Every layer that names a directory
outranks it — --dir, PTAH_DIR, PTAH_MIGRATIONS_DIR, and atlas.hcl
migration.dir — and a --dir naming a directory that is not there fails
rather than quietly reading ./migrations:
ptah-compat migrate apply --url "$DATABASE_URL" --dir file://migrtions# Error: atlas migrate apply --dir: open migrations directory: openat migrtions: no such file or directoryThe default also does not skip anything. A defaulted directory reaches the
atlas.sum gate exactly as an explicit one does, so an unhashed or drifted
./migrations is still refused with Error: checksum file not found or
Error: checksum mismatch.
The writing verbs create the directory they are pointed at, including missing
parents: ptah-compat migrate new add_users --dir file://db/migrations creates
db and db/migrations. A path component that already exists and is not a
directory is still refused, and nothing is written.
Worked example: an Atlas-format directory
Section titled “Worked example: an Atlas-format directory”Atlas-style migration files can include migration.sql, down.sql, the
default checks.sql, and ordered checks/*.sql sections inside txtar archives.
Ptah executes migration.sql on apply and down.sql on rollback, and enforces
every check file as a pre-migration gate. Each assertion must be a top-level
SELECT returning exactly one column and one row with a truthy scalar. All
assertions in a file must pass unless a file-level -- atlas:assert oneof
requires at least one; an empty oneof file fails closed.
Dialect-aware splitting preserves PostgreSQL escape strings and MySQL/MariaDB semantic comments instead of rewriting assertion SQL. Checks use a dedicated physical session that Ptah discards afterward. Transaction-capable drivers roll back, while ClickHouse uses the disposable session directly because its driver does not implement transactions.
For MySQL/MariaDB executable comments, Ptah validates the effective SQL and
evaluates version guards against the connected server. Numeric prefixes shorter
than five digits remain part of the executable SQL body. Hidden statement
delimiters and non-SELECT effective bodies fail closed before query execution.
A guard counts as live whenever it is less than or equal to the server’s own
version number (major*10000 + minor*100 + patch), so /*!80000 ... */ runs on
MySQL 8.0 and on every later release too. A large guard is not a way to keep SQL
inert: MySQL 26.7 encodes as 260700 and honors /*!99999 ... */. MariaDB
ignores the MySQL 50700-99999 band whatever its own version is, and treats
higher numbers as MariaDB versions.
A failure aborts before any body statement, matching Atlas’s enforcement point. Ptah ignores unrelated embedded files.
Create an Atlas-style migration:
-- atlas:txtar
-- migration.sql --CREATE TABLE users ( id integer PRIMARY KEY, email text NOT NULL UNIQUE);
-- down.sql --DROP TABLE users;Name the file with a migration version, for example:
migrations/20260721120000_create_users.sqlHash and validate the directory:
ptah-compat migrate hash --dir file://migrationsptah-compat migrate validate --dir file://migrationsSuccessful hash and validate commands are silent. The hash command writes
migrations/atlas.sum; commit that file with the migration.
Apply it, then check status:
ptah-compat migrate apply \ --url "$DATABASE_URL" \ --dir file://migrations
ptah-compat migrate status \ --url "$DATABASE_URL" \ --dir file://migrationsExpected output includes:
Migrating to version 20260721120000 from 1 pending migrations.Migration complete. Current version: 20260721120000Migration Status: OK -- Current Version: 20260721120000 -- Next Version: Already at latest version -- Executed Files: 1 -- Pending Files: 0migrate status is the one compatibility verb whose output a pipeline parses
with a machine rather than reads, so it mirrors the Atlas report shape by
default — field names, sentinel strings and value encodings included. A deploy
gate written as grep -q 'Migration Status: OK' works unchanged, -- Current Version: matches, and a database with nothing applied reports the sentence
No migration applied yet rather than 0. Native
ptah migrations status keeps its own block: the two
surfaces are allowed to differ and only the compatibility one is a contract.
Recovering from a migration body that failed part-way
Section titled “Recovering from a migration body that failed part-way”When a transactional body fails and Rollback succeeds, ptah-compat removes
the zero-progress revision row. The next apply retries the whole file without
--allow-dirty, matching Atlas. Native ptah migrations up keeps its durable
failure record instead.
On MySQL and MariaDB a file rollback does not always reach zero progress: the
server may commit around DDL. ptah-compat does not guess the boundary from SQL
keywords. The Atlas revision row is an InnoDB witness updated on the same
physical transaction before and after each statement. An implicit server commit
makes the matching applied count and partial_hashes durable with the body;
an ordinary rollback removes both the user DML and its witness. A plain-DML body
that rolls back leaves no committed prefix and is retried whole. A witnessed
DDL/DML prefix stays dirty because retrying it would repeat committed SQL.
This recovery mode requires InnoDB for the Atlas revision table, the session’s
default storage engine, and every existing base table in the selected database.
An explicit non-InnoDB CREATE, ALTER, or storage-engine setting is refused
before the migration runs. Resetting an engine setting to DEFAULT is refused
because its effective value can differ from the verified session default.
CREATE TABLE ... LIKE is also refused because the new table inherits an engine
that the session default does not prove.
On MySQL, the migration account must hold TRIGGER at database or global scope
so Ptah can see a complete trigger catalog. MariaDB exposes each trigger’s
identity and target table without TRIGGER, so it does not require that
privilege for this catalog check. Ptah refuses MySQL file mode when it cannot
prove complete visibility.
The MySQL grant must be global or must name the selected database exactly in
SHOW GRANTS. Ptah decodes escaped literal wildcard characters in the database
name, but deliberately does not infer coverage from an unescaped % or _
pattern because MySQL’s partial_revokes setting changes that pattern’s
meaning.
MySQL-family file bodies fail closed on SQL whose effects Ptah cannot tie to
the InnoDB witness:
- Transaction controls such as
BEGIN,COMMIT, andSET autocommit. - Durable server-state operations such as
SET GLOBAL,SET PERSIST,RESET, andCREATE,ALTER, orDROP DATABASEorSCHEMA. - Any
sql_modeassignment, because changing grammar or quoting rules after preflight can make the server execute SQL that Ptah did not inspect. SELECTorTABLEwithINTO OUTFILEorINTO DUMPFILE, which writes outside the InnoDB transaction.USEand qualified references to another database. The connection URL must name the database whose engines Ptah validates.- Executable comments, nested or dynamic SQL, and table locks.
- Definitions of indirect database objects, references to existing views or trigger-bearing tables, and stored-routine calls.
- Custom migration functions, whose inner statements are opaque to Ptah.
- Statement interceptors, which can replace inspected SQL with another execution path.
Preflight also refuses a session that already enables parser-changing
ANSI_QUOTES, MSSQL, or NO_BACKSLASH_ESCAPES behavior, including through
the connection DSN or a server default.
Statement-level rejection errors identify the statement number and safety class without echoing the SQL. Migration-function and interceptor refusals identify the affected direction instead because their inner statements are opaque.
MySQL and MariaDB do not support --tx-mode all, along with every other target
that commits DDL as it runs; see
what --tx-mode all cannot carry.
Ordinary session settings
remain valid. Ptah runs the body on one pinned session, discards it afterward,
and replays safe settings such as SET SESSION time_zone from a verified
committed prefix before an automatic retry.
Temporary tables are also allowed: 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.
A same-named temporary table cannot shadow the Atlas revision table: before reading or writing revision metadata, Ptah refuses and discards a pinned session that already contains that temporary table. It also rejects statements that directly reference the metadata relation, including through a schema-qualified name. A durable unknown-outcome witness blocks automatic retry until the database is inspected and the revision is repaired.
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.
When a statement committed under --tx-mode none, or its outcome is unknown,
ptah-compat preserves the revision row. migrate status reports that row as
a half-applied file, because Current Version counts it:
Migration Status: PENDING -- Current Version: 20260721120100 (1 statements applied) -- Next Version: 20260721120100 (1 statements left) -- Executed Files: 2 (last one partially) -- Pending Files: 1
Last migration attempt had errors: -- SQL: ALTER TABLE users ADD COLUMN email TEXT -- ERROR: failed to execute migration SQL: ...Fix the migration, rerun ptah-compat migrate hash, and rerun the apply with
--allow-dirty. The retry reuses the dirty row instead of recording a second
one and skips the statements the earlier attempt committed — under
--tx-mode none above, statement 1 — only after proving that committed source
prefix is unchanged. Atlas-format rows use the cumulative partial_hashes
entry at applied. Editing the unapplied suffix is allowed, and a later retry
failure cannot lower applied below that committed prefix even when the
transaction mode changed. Atlas needs no flag here; --allow-dirty stays
required so a half-applied migration is never resumed by accident.
The dirty source file must still be present. If an exact Flyway identity remains
dirty after its file is removed, --allow-dirty refuses before pre-migration
checks, apply preflight, or another migration body and names that exact identity.
Restore the reviewed source file or repair the recorded history explicitly; a
different pending file cannot supply the missing body or committed-prefix
contract.
Automatic resume refuses and names ptah migrations repair --version <v> when
a run was interrupted mid-statement, the statement count changed, the committed
source prefix changed, or partial_hashes is malformed or disagrees with
applied. It also refuses negative applied or total values and
applied > total. Legacy Atlas rows without partial_hashes may resume only
while the stored full-file hash still matches. Revision listing, status, and
version operations reject the same invalid counters instead of classifying an
equal negative pair as clean.
Rolling back
Section titled “Rolling back”Roll back using the down.sql section. A bare ptah-compat migrate down reads
the Atlas revision rows migrate apply wrote and starts the rollback without
reading stdin:
ptah-compat migrate down \ --url "$DATABASE_URL" \ --dir file://migrations \ --to-version 0Expected output ends with:
✅ Migration rollback completed successfully!Database is now at version: 0Review the URL, migration directory, and target before running the command.
The Atlas-compatible surface has no confirmation prompt and does not accept
the native --confirm flag. Native ptah migrations down keeps its prompt.
Ptah validates every selected down body before rollback starts. If one is missing, the command leaves both the schema and Atlas revision rows unchanged. Dry runs use the same dirty-state, checksum, checkpoint, and down-body validation path as real rollbacks, while suppressing schema and revision writes.
If a rollback fails after execution starts, the Atlas-format revision row stays
dirty and records Ptah/down in operator_version. Resume through the native
repair command because the drop-in surface intentionally has no repair verb:
ptah migrations repair \ --db-url "$DATABASE_URL" \ --migrations-dir ./migrations \ --dir-format atlas \ --revision-format atlas \ --version 2 \ --resume-from 2Use the failed version and next down-statement number reported by status. If
the compat command used --revisions-schema, pass that value as
--migrations-schema here.
Add --dev-url to reset a disposable dev database, replay the migration
directory to the target’s current version, and verify the rollback there before
the target is touched:
ptah-compat migrate down \ --url "$DATABASE_URL" \ --dev-url "$DEV_DATABASE_URL" \ --dir file://migrations \ --to-version 0The dev database must select a different live database or catalog from
--url. Ptah rejects equivalent URL aliases first, then connects and verifies
the actual dialects and selected database/catalog names before resetting the
dev database. Equal live names fail closed across different endpoints.
Native ptah migrations commands read the same directory when --dir-format atlas is passed; the native lifecycle is documented under
Versioned migrations. If parsing fails, force
--dir-format atlas and inspect the migration file for section names: Ptah
recognizes migration.sql and down.sql, and other section names are not
executed.
Apply a migration directory
Section titled “Apply a migration directory”ptah-compat migrate apply reads a local Atlas migration directory and records
runtime history in Atlas revision-table format by default. The optional
positional amount applies only the first N pending migrations. Use
--baseline to mark earlier migration files as applied without executing their
SQL bodies before applying the remaining pending migrations.
The directory’s integrity is checked before anything executes, matching official Atlas:
- An Atlas directory whose
atlas.sumdoes not verify is refused withError: checksum mismatch. - An Atlas directory that carries no
atlas.sumat all is refused withError: checksum file not found; runptah-compat migrate hashonce and commit the file. A directory holding no top-level.sqlfile is not a checksum error — it reportsNo migration files to executeand exits0, matching Atlas. The scan is top-level-only because the executed set is the setatlas.sumcovers: a.sqlfile in a subdirectory, or a top-level.SQL, is not a migration on either tool (#976). Each such file is named on stderr as declined, which Atlas does not do — see Integrity and safety. - Directories read through
?format=(goose, flyway, liquibase, dbmate, golang-migrate) are gated on theatlas.sumthe source directory carries, verified before the source layout is parsed. The covered file set is the one Atlas uses for that layout, so a golang-migrate down file and a Flyway undo file are not covered, and a layout whose covered set is empty is not refused. Runptah-compat migrate hash --dir 'file://migrations?format=goose'to write it. “Not covered” also means “not executed”: for every layout the set apply runs is the set the verified checksum covers.
Both refusals exit 1 with output identical to ptah-compat migrate validate
on that directory, no migration runs, and the target database is never created.
Migrations whose first line is the -- atlas:checkpoint directive get
measured Atlas checkpoint semantics — a fresh database applies only the
latest checkpoint plus later migrations, and a database that already applied
pre-checkpoint history skips the checkpoint silently.
Adopting a database that already has objects
Section titled “Adopting a database that already has objects”migrate apply refuses to adopt a database that already holds objects no
migration recorded, matching Atlas. What counts as such an object depends on
what the URL pinned, and the two answers are different.
A URL that pins a schema — ?search_path=public on PostgreSQL, or a
database on a MySQL URL — puts the run in schema scope. The refusal names a
table in that one schema:
Error: sql/migrate: connected database is not clean: found table "legacy_stuff" in schema "public". baseline version or allow-dirty is requiredOn SQLite the same refusal reports a count instead of a name:
found multiple tables: 2. Views, sequences, and tables in other schemas are
not tables in the connected schema and do not trigger it, and neither does the
revision table itself.
A plain PostgreSQL URL that pins no search_path puts the run in realm
scope, where the whole database is under review and the operand is schemas
rather than tables. An empty extra schema is enough:
Error: sql/migrate: connected database is not clean: found schema "extra". baseline version or allow-dirty is requiredAt that scope only two things are tolerated: an empty public, and the schema
holding this run’s revision table. Anything else — another schema, empty or
not, or a table in public — refuses, and the refusal names the first offender
by name. A public holding a table is reported as found schema "public", not
as a table. A revisions schema holding more than the revision table reports a
count: found 2 tables in schema "atlas_schema_revisions".
Only the search_path query parameter selects schema scope. A search path set
through libpq’s options=-c search_path=… moves the session but leaves the run
at realm scope, which is what Atlas does.
The check is an adoption gate, not a standing drift check. It runs only while
the revision table holds no rows, so it fires on the first apply against a
database somebody else’s tooling owns and never again — a managed database that
later grows an unmanaged table applies its next migration normally. The refusal
also fires under --dry-run and on a directory with nothing pending, because
the question it answers is about the database rather than about the work.
Two flags opt in, and they cannot be combined:
--allow-dirtyapplies every pending migration against the existing schema.--baseline <version>records history as starting at that version and applies only what comes after it.
Passing both exits 1 with
Error: sql/migrate: baseline and allow-dirty are mutually exclusive before
anything is recorded.
The gate is enforced on PostgreSQL, MySQL, MariaDB, and SQLite. Other dialects
are not gated, because the behavior to match has not been measured on them.
Realm scope is enforced on PostgreSQL only: a MySQL URL that names no database
is refused by the connection before the gate is reached, so that combination
never applies anything either. Native
ptah migrations up has no equivalent gate; see
#1231.
The revision table lands in the schema Atlas uses, atlas_schema_revisions,
whenever the URL names a PostgreSQL-family database and neither
--revisions-schema nor the project file says otherwise. That is what lets the
same database be handed between the two binaries: each reads the history the
other wrote, and reports No migration files to execute rather than treating
the database as never migrated.
The default is scoped to that family because the location is a per-dialect fact. On MySQL a schema is a database, so the table stays in the one the connection opened; SQLite has no schema to name at all.
ptah-compat migrate apply 2 \ --url "$DATABASE_URL" \ --dir file://migrationsSupported Atlas apply flags include --dry-run, --tx-mode, --exec-order,
--allow-dirty, --baseline, --revisions-schema, --lock-timeout,
--to-version, --lock-name, --skip-lock, and --format. The pinned
community binary registers every one of those except --to-version,
--lock-name, and --skip-lock, which are documented on the wider Atlas
distribution’s migrate apply and adopted here under
#951.
--format executes a Go template against a Ptah apply result that mirrors
Atlas’s public apply-template fields:
Pending, Applied, Current, Target, Start, End, Driver, URL, and
Dir; {{ json . }} emits the same result as JSON with database credentials
redacted. With --env, Ptah can read env.url, migration, and
format.migrate.apply from atlas.hcl. Dry-run plans read the stored Atlas
revision rows and include only migrations that a real apply would select. They
also run the same dirty-state, checksum, execution-order, and transaction-mode
validations as a real apply.
Apply an inserted migration out of order
Section titled “Apply an inserted migration out of order”The default --exec-order linear refuses a pending migration below the current
version. Use non-linear order when the insertion is intentional:
ptah-compat migrate apply \ --url "$DATABASE_URL" \ --dir file://migrations \ --exec-order non-linearAn atlas.sum entry is a running hash over the files before it. Adding an
earlier migration changes later entries even when those later files are
byte-identical. Ptah verifies them against the chain projected from applied
migrations, excluding the pending insertion.
After the insertion succeeds, Ptah reconciles clean revision rows to the new chain while it still holds the migration lock. It computes the full update set first, then commits every affected row in one transaction where the driver supports transactions. A failed row update leaves the whole set on the prior chain, so the next apply can retry it. A dry run, execution failure, or process stop before the revision is applied never rewrites later clean rows.
Equal non-zero application timestamps remain one candidate group. Ptah retries only when exactly one prior applied-set projection explains every affected row and each later row still matches the projection from its own application-time group. Mixed old and new hashes, ambiguous groups, and edited files fail closed.
ClickHouse permits one synchronous checksum mutation. A reconciliation that needs multiple row updates fails before the first mutation because the configured driver has no multi-statement transaction.
An env for_each can select several database targets. migrate apply runs
them sequentially in stable expansion order and stops at the first failure.
Formatted output contains one document per attempted target with one newline
between adjacent documents. A structured execution failure stays in that
target’s report; stderr remains empty and the process exits 1.
--to-version bounds the run at a migration version: every pending migration up
to and including that version runs, and nothing above it does. A version the
directory does not carry is refused before any migration executes, and the bound
cannot be combined with the positional amount, because the two select
different prefixes and neither outranks the other. Under --dry-run the bound
narrows the reported plan the same way.
ptah-compat migrate apply \ --url "$DATABASE_URL" \ --dir file://migrations \ --to-version 20240101000002--lock-name replaces the name of the session advisory lock that serializes
migration runs (ptah_migrate by default). Two runs serialize only when they
name the same lock, so this is how a Ptah run coordinates with another tool on
the same database. A lock another process holds makes the run wait, bounded by
--lock-timeout; an elapsed timeout fails the run before any migration
executes. An empty value is refused rather than falling back to the default
name.
--skip-lock acquires no lock at all: no wait, no timeout, and no
serialization against another runner. The lock is taken before the pending set
is computed, so a run with nothing left to apply still waits on a held lock,
and exits 0 under --skip-lock in the same state. It cannot be combined with
--lock-name, because there is no lock to name. On dialects with no
advisory-lock semantics — SQLite, ClickHouse, CockroachDB, and Spanner — an
explicit --lock-name prints a note on stderr naming the lock that was not
acquired.
Atlas migration files may override global file or none with a leading
header. A blank line after the header is accepted but not required:
-- atlas:txmode fileALTER TABLE users ADD COLUMN email TEXT;The header must sit in the unbroken run of line comments that begins on line 1,
each comment starting in column 1. Measured on Atlas CE v1.3.0 with
migrate apply --tx-mode all over one-statement directories, atlas:txmode none is honored on line 1 and on line 2 below another comment, and ignored
when a blank line precedes it, when it is indented, when a blank line separates
it from an earlier comment, and when it follows the statement. Ptah matches
every one of those, and adds the thing CE does not do: an ignored directive is
reported at WARN on stderr with its file, line and text, rather than dropped
in silence. Ptah’s own -- +ptah directives answer to the same “before the
first executable statement” rule with a more forgiving acceptance inside it —
see Apply.
The accepted file values are file and none. File-level all, unknown
values, duplicate values, and any explicit file mode under global all fail
before the affected migration body or revision row changes. Validation follows
the selected plan: global all validates the complete selected batch first;
global file and none validate each file as execution reaches it. An amount
or baseline that excludes a malformed file does not validate that file.
On ptah-compat, these failures use Atlas’s leaf diagnostic without Ptah’s
internal error applying migrations: wrapper. The native command keeps its
error running migrations: context.
Inside a Ptah-supported txtar migration, migration.sql and down.sql carry
independent modes. Ptah rejects a transaction-mode header placed before
-- atlas:txtar; this safety check prevents the archive from being executed as
one plain SQL stream. Atlas CE v1.3.0 instead ignores section-local modes and
can classify that malformed outer-header shape as plain SQL, so this contour is
an intentional safety difference rather than a parity claim.
A clean successful ptah-compat migrate apply writes nothing to stderr,
matching Atlas CE: there is no progress narration, in a dry run or otherwise,
so --format output survives the usual CI idiom of folding both streams
together.
ptah-compat migrate apply --url "$DATABASE_URL" --dir file://migrations \ --dry-run --format '{{ json . }}' 2>&1 | jqThree things still reach stderr, by design. A command that fails prints its
Error: … diagnostic there and exits 1. A Warn-level runtime diagnostic that
exists on no other channel — such as function ordering or a dev database that
would not close — is still reported. An atlas.hcl name that Atlas CE accepts
without acting on also produces a location-aware warning that the construct has
no effect. Valid circular foreign keys are rendered in two phases and do not
produce a warning. None of these diagnostics appears on a clean run.
ptah-compat migrate down holds the same contract, with or without --format.
Without --format it forwards to the native ptah migrations down, which
starts its own run log; the Atlas surface pins that log to the same Warn
threshold the rest of this binary uses, so a successful dry run and a successful
rollback both leave stderr empty (stokaro/ptah#969).
The equivalent native command, ptah migrations up --dry-run, does narrate each
statement it would execute through its run log; that narration is selected by
the native --log-level and --log-format flags. The Atlas surface does not
model those flags, but a forwarded verb passes through any flag its native
target registers, so ptah-compat migrate down --log-level info is accepted and
restores the full narration. See Apply migrations.
The apply path executes every Atlas OSS migration directory format selected by
migration.format or the directory URL ?format= parameter: atlas,
golang-migrate, goose, flyway, liquibase, and dbmate. The native
atlas format is captured unchanged, preserving atlas.sum verification and
down migrations.
Every other format is captured first and then converted in memory to Atlas
single-file, up-only migrations, so apply executes only the source tool’s
forward (up) SQL and never its down, rollback, undo, or metadata section. This
shares format parsers and up/down semantics with ptah-compat migrate import.
Conventional Liquibase import adds a persistence adapter that splits changesets
into numeric Atlas files; direct apply retains its numbered-file requirement and
source-file boundary.
An explicit ?format= query on the effective directory URL, from either
migration.dir or CLI --dir, overrides the migration.format project
default, matching Atlas; an empty query value selects the native atlas
format.
The local directory is opened through a rooted handle and captured twice before
the target database is opened. The command aborts when the captures differ or a
migration symlink escapes the root. Apply planning, execution, atlas.sum
verification, and --format output all use the resulting immutable filesystem
instead of reopening the path.
# Apply a Goose directory directly — no separate import step.ptah-compat migrate apply --url "$DATABASE_URL" \ --dir "file://migrations?format=goose"Flyway versions are compared component-wise like Flyway itself (V1.5 sorts
before V2, V1.10 after V1.9). Ptah gives each file two deliberately
separate values:
- The exact Flyway token is the revision identity. Apply records
1.5,01,.foo,1R, or another opaque token byte for byte; set addresses that token;--baselineand the extended--to-versionaddress that same token; and status and lint print it. An ordinary version token ending inRremains versioned rather than inheriting native Atlas repeatable behavior. - A numeric key governs execution order and linearity. Uniquely scored ordinary
versions keep a stable key when another file is inserted. Equal-order tokens
such as
1and01, or two nonnumeric tokens in the same score band, use a walk-position tie slot; a surviving baseline also occupies its own lower band.
The ordering key uses a fixed-width major.minor.patch projection when the
token has that shape (minor and patch 0–99) and also covers timestamp and
nonnumeric forms. It is an implementation detail, not a version an operator
must copy from output or a revision table.
Exact identity lets Atlas CE consume a history Ptah wrote. Reusing a history in the other direction can still stop at Ptah’s per-revision checksum validation: CE and Ptah encode those checksums differently, so Ptah refuses rather than silently adopting a body it cannot verify.
A same-token V2 to B2 transition has one extra ambiguity when both files
carry identical SQL. CE records both as ordinary applied rows and retains
neither source prefix; Ptah therefore refuses that CE row by name. A baseline
executed by Ptah carries an internal combined applied/baseline marker, still
displayed as applied, so its later runs are
provable without treating it as a --baseline history boundary. migrate set
adds the manual bit to that marker and displays manually set; Atlas CE can
still read the row, while Ptah retains enough information for a later apply.
A successful explicit --baseline that selects the surviving B2 is different:
its pure baseline row carries a durable source-baseline marker and settles that
exact identity plus every migration below its boundary. Baselining V2 before
introducing B2 does not carry that marker, so the later ambiguous replacement
still fails closed instead of silently skipping the baseline body.
Persisted exact tokens remain visible after the Flyway source mapping no longer
owns them. This keeps CE’s V.foo revision .foo readable after a baseline
squashes it or its source file is removed, without materializing a pending
migration. The exact retired token still participates in Flyway source-order
checks. Atlas’s measured .atlas_cloud_identifier bookkeeping row remains
excluded. Status reports Current using CE’s textual maximum over applied source
tokens while numeric high-water remains internal to execution.
migrate set answers a different question from status and linearity: whether a
retired row lies above the selected target in Flyway’s numeric component order.
It therefore keeps retired V9 history when setting V10, but removes retired
V2 history when setting V1. When two source tokens need missing file-role or
walk-position context to break a tie, such as 01 versus 1 or x versus
y, Ptah refuses before changing revision metadata instead of guessing from
their byte order.
Ptah-written mapped rows carry operator_version='Ptah/source-identity'. The
marker does not change the exact version or Atlas-visible status; it proves that
a numeric revision value is a source token rather than an older Ptah ordering
key. If a retired numeric token collides with a current migration’s historical
ordering-key candidate, apply refuses without repair SQL instead of rewriting
the valid history row.
That numeric order governs atlas.sum and execution, and it is not the
comparison that decides whether a migration was added out of order. Flyway
answers that on the version token as text, where "10" sorts below "2", so a
project’s tenth migration added beside an already-applied V2__x.sql is refused
rather than executed:
Error: error applying migrations: out-of-order pending migrations for currentversion "2": "10" (use --exec-order=non-linear to apply or--exec-order=linear-skip to ignore)The refusal names only the exact source version; normal direct-operation
output does not expose the internal numeric order key as the active migration
identity. --exec-order=non-linear runs the migration, --exec-order=linear-skip
leaves it pending, and a directory with no recorded history is unaffected —
applying V2__x.sql and V10__y.sql together for the first time runs both, in
that order. A repeatable (R__) is version "" to Atlas, which sorts below
every token, so one added to a database that already has history is refused by
the same rule. Once its empty revision is settled, editing and rehashing the
single repeatable remains a no-op, matching the pinned community binary.
Several inputs still fail before Ptah opens the target database rather than guess
at semantics: unknown formats; goose files whose directives are out of order;
dbmate files missing their up directive; and two source files that resolve to the
same identity. A single Flyway repeatable migration is converted and executed
with Atlas CE’s exact empty revision identity; its reserved numeric slot is only
its ordering key. Two repeatables would share that empty identity, so Ptah
refuses them before mutation instead of reproducing Atlas CE’s partial apply and
panic. See
stokaro/ptah#742 and
stokaro/ptah#1098.
Goose directive parsing
Section titled “Goose directive parsing”Goose directives are parsed as a state machine, not filtered line by line, and
the accepted set matches Atlas. Directive names are case- and space-sensitive
exactly as Atlas matches them: -- +goose Up is a directive, -- +goose up,
-- +goose Up and -- +Goose Up are not. A name may carry trailing text
(-- +goose Up extra is still Up). Lines Atlas does not recognize as
section directives stay in the migration body and are executed with it. The
exact whole-file -- +goose NO TRANSACTION line becomes Atlas
transaction-mode metadata in Ptah’s converted view, so a direct migrate apply
runs that source migration outside a transaction. Ptah consumes the exact
annotation as metadata even inside StatementBegin; it does not leak into the
SQL body. Near-matches and unrecognized annotation-like lines inside a statement
block remain literal SQL; recognized section-changing directives still refuse.
A goose file that contains no section directive at all is executed in full:
the whole file is the migration. Such a file has no rollback section that could
leak onto the apply path, so there is nothing to protect against. A file with a
broken directive set is a different thing and is still refused — Down
before any Up, a second Up, StatementBegin outside a section, an
unmatched StatementEnd, or any section directive inside a StatementBegin
block. See stokaro/ptah#981.
The up section runs from the start of the file through the first Down, so
SQL written above the Up directive is executed rather than silently dropped.
An intentionally empty up section is recorded as an applied revision with zero
statements rather than being skipped.
Deliberate divergences
Section titled “Deliberate divergences”Three behaviors differ from Atlas on purpose. All three are cases where matching would mean reproducing a defect, so Ptah is stricter — never looser — than Atlas.
A Goose directive with the wrong case
Section titled “A Goose directive with the wrong case”-- +goose down instead of -- +goose Down.
Atlas exits 0. The typo is not recognized, so the line folds into the body as a comment and the rollback SQL under it executes — the migration is created, dropped, and recorded as successful.
Ptah refuses, naming the line and the correct spelling. A case error in a directive must not silently roll back a migration.
A dbmate file with no -- migrate:up
Section titled “A dbmate file with no -- migrate:up”Atlas exits 0, records the revision with 0 of 0 statements and creates nothing,
so the migration is marked done and never runs. migrate import then writes a
zero-byte file over the authored SQL and hashes it into atlas.sum.
Ptah refuses, because nothing in the file would execute.
An Atlas directory holding an R-suffixed migration
Section titled “An Atlas directory holding an R-suffixed migration”For example 1R_view.sql or R__view.sql.
Atlas exits 0 and executes it, keyed on the opaque version string the file name
spells (1R, R).
Ptah refuses, naming every such file. Ptah’s migration identity is an int64
version and a repeatable has none, so the only alternatives were executing it
under a version no other tool records, or dropping it — which is what Ptah used
to do, silently.
An R-suffixed file only reaches a Ptah directory from outside: the community
binary’s own migrate import writes 1R_name.sql, and ptah-compat migrate import writes the same migration on a reserved numeric slot instead, so a
directory Ptah imported is unaffected. Rename the file to <version>_<name>.sql
and re-run ptah-compat migrate hash to execute it. Ordering is not preserved by
that rename: Atlas sorts directory entries by file name, so 1R_view.sql runs
before 1_users.sql, while 2_view.sql runs after it.
Ptah refuses only exact near-miss spellings of the four section directives.
Prose that merely begins with one (-- +goose up to date) and unrecognized
names (-- +goose Frobnicate) stay comments, as they do in Atlas.
The pinned community binary does not register migrate apply --dir-format, and
Ptah rejects it on migrate apply too; the directory format is selected there
by the ?format= query on --dir, as
Apply a migration directory describes.
The direct migrate down --format path uses the same snapshot for rollback
planning, optional --dev-url shadow verification, target execution, and the
rendered report. migrate status, migrate lint, and migrate set also capture
their local directory before database work and do not reopen it later.
Generate a migration with migrate diff
Section titled “Generate a migration with migrate diff”ptah-compat migrate diff accepts a local --dir migration directory, a
directly connectable --dev-url, and one desired schema source through
--to. The source can be one or more local schema files, one directly
connectable database URL, one local Atlas migration directory, or one env://
reference into the evaluated atlas.hcl environment. With --env, Ptah can
read env.schema.src, env.dev, migration.dir, format.migrate.diff, and
supported diff policy from atlas.hcl.
Ptah snapshots the desired schema first, cleans the dev database, and replays
the migration directory into it. It compares the replayed state to the
snapshot, cleans the dev database again, and only then writes Atlas-style
.sql migration files plus atlas.sum when changes exist. The final cleanup
also runs after replay, introspection, comparison, or context-cancellation
failures.
Use a disposable dev database. Ptah only reads a database used as --to; it
never cleans or mutates that database. Ptah rejects a desired database that
identifies the same host, port, and database as --dev-url, even when
credentials, connection options, scheme aliases, or an explicit default port
differ. Repeated --schema values filter both sides of the comparison and the
generated output.
They do not narrow the dev database realm replayed and cleaned by Ptah.
If atlas.sum already exists, Ptah validates it before replaying migrations
and fails on checksum drift instead of silently rehashing edited files. Ptah
holds the migration-directory lock while it captures and verifies one
immutable directory snapshot, replays that exact snapshot, and publishes the
result.
It also holds an exclusive dev-database lock from desired-schema resolution through final cleanup. PostgreSQL, YugabyteDB, MySQL, MariaDB, and SQL Server use session advisory locks. SQLite, ClickHouse, and CockroachDB use an operating-system lock keyed by normalized database identity. The latter coordinates Ptah processes on one host; cross-host ClickHouse and CockroachDB replay is unsupported.
A dialect without a safe locking mechanism fails before Ptah cleans the dev
database. Generated migration files are staged before publication, and
atlas.sum is atomically replaced last as the batch commit marker. An
OS-backed lock prevents cooperating processes from planning against the same
directory concurrently and is released by the operating system if a process
exits unexpectedly.
The generated versions and checksum are derived from the captured snapshot.
Ptah rejects a migration added after that capture instead of publishing above
an unreplayed file. atlas.sum is updated only after every migration file of
the run was written. A failed write rolls the generation back immediately.
If the process exits between publishing the SQL files and publishing
atlas.sum, the durable publication journal remains next to the migration
directory; the next lock holder compares the checksum commit marker and either
finalizes the committed batch or removes only the hard-linked files owned by
the interrupted batch.
ptah-compat migrate diff add_users \ --dir file://migrations \ --to file://schema.sql \ --dev-url "sqlite://dev.db"Expected output includes:
Created migration file: .../migrations/20260721120001_add_users.sqlUpdated migration checksum: .../migrations/atlas.sumUse a live database as the desired schema:
ptah-compat migrate diff mirror_schema \ --dir file://migrations \ --to "$DESIRED_DATABASE_URL" \ --dev-url "$DEV_DATABASE_URL"Use an evaluated project attribute as the desired schema:
ptah-compat migrate diff mirror_schema \ --config file://atlas.hcl \ --env local \ --to env://urlenv://src and env://schema.src resolve the selected environment’s schema
sources. env://url and env://dev resolve database URLs, while
env://migration.dir resolves the configured migration directory. When
--to is omitted, migrate diff resolves the selected environment’s
schema.src through the same typed path, including database-valued defaults.
An env:// reference must be the only --to value. Mixed source kinds,
multiple database or migration-directory sources, nested env:// references,
dialect mismatches, and source/dev aliases fail before Ptah connects to the
dev database.
Use an Atlas migration directory as the desired state:
ptah-compat migrate diff import_history \ --dir file://migrations \ --to file://desired-migrations \ --dev-url "$DEV_DATABASE_URL"The desired directory is checksummed and replayed on the dev database to capture a schema snapshot. Ptah cleans the dev database before it evaluates the output directory. A source loading failure therefore does not create the output migration directory, and a successful source replay does not leave its objects behind.
Atlas OSS registers migrate diff --dry-run as a hidden flag. Ptah accepts the
same hidden flag and prints the generated SQL instead of writing a migration
file or updating atlas.sum:
ptah-compat migrate diff add_users \ --dir file://migrations \ --to file://schema.sql \ --dev-url "sqlite://dev.db" \ --dry-runUse --lock-timeout to bound waiting for both the migration-directory lock and
the exclusive dev-database lock. The default migration-file format matches
Atlas’s two-space SQL indentation template. Use --format to render the
generated migration SQL through Atlas-style Go templates with sql and
.MarshalSQL, for example to disable indentation:
ptah-compat migrate diff add_users \ --dir file://migrations \ --to file://schema.sql \ --dev-url "sqlite://dev.db" \ --format '{{ sql . "" }}'With --edit, the generated migration files open in $VISUAL or $EDITOR
before atlas.sum is finalized, so hand-tuned SQL still validates; --edit
cannot be combined with the hidden --dry-run flag because dry runs write no
migration file to edit.
--qualifier matches Atlas’s single-schema semantics: every object named by
the generated statements is prefixed with the custom schema qualifier, so the
file can be applied to a schema other than the one it was planned against:
ptah-compat migrate diff add_items \ --dir file://migrations \ --to file://schema.sql \ --dev-url "postgres://user:pass@localhost:5432/dev" \ --qualifier tenant# generates: CREATE TABLE "tenant"."items" (...)The qualifier is supported on PostgreSQL, CockroachDB, YugabyteDB, MySQL, and MariaDB dev
databases and applies to tables, columns’ foreign-key references, table-level
constraints, indexes, and drops. Invalid values (control characters, .,
quotes), unsupported dialects, plans spanning several schemas, and statement
kinds Ptah cannot re-qualify yet (for example enum types) fail explicitly
before any migration file or checksum is written. As with Atlas, replaying a
directory that contains qualified migrations requires the qualifier schema to
exist on the dev database.
--schema accepts repeated or comma-separated schema names and narrows the
replayed dev database state plus the resolved desired state before the diff is
planned. With diff.concurrent_index.create = true in the selected
atlas.hcl env, newly added indexes are planned as PostgreSQL CREATE INDEX CONCURRENTLY statements.
Files carrying such statements start with the Atlas -- atlas:txmode none
file directive, which both Atlas and Ptah honor by executing the file outside
a transaction.
Because those statements must not silently strip transaction safety from
ordinary DDL, a mixed plan is split the same way Ptah’s native generator
splits it: a <name>_transactional file with the transactional statements
followed by a <name>_concurrent_indexes file tagged -- atlas:txmode none;
mixes that cannot be split automatically (for example enum value additions
alongside table changes) are refused.
The rollback is planned through the same dialect and capability set. Each
reverse index operation selects concurrency independently. A concurrent create
becomes the exact table-qualified concurrent drop when supported and a blocking
drop otherwise; the same rule applies to the reverse create after a concurrent
drop. An explicitly requested unsupported forward operation still fails before
a migration or atlas.sum is published. On MySQL and MariaDB, the rollback
also removes a backing index the forward foreign-key addition caused the server
to create. It preserves any prior or same-run covering index, even under a
different name, and refuses a plan that later removes every such index.
All five foreign directory layouts carry the ordinary rollback. Goose can also
represent a no-transaction requirement from either direction with its
whole-file -- +goose NO TRANSACTION directive. That directive makes both
sections non-transactional, including when only one direction requires it. The
other four layouts remain fail-closed when either direction requires
no-transaction execution because their safe transaction metadata has not been
proven. The native Atlas layout remains forward-only and carries -- atlas:txmode none on its own file when required.
A docker:// dev database is provisioned on the verbs that take one: the
container is started, used and removed by the command.
The publication boundary
Section titled “The publication boundary”migrate diff opens the migration directory and its parent once, before
anything is staged, and keeps both handles for the rest of the run. The parent
is held as well because the publication journal and the commit marker sit
beside the directory, so an interrupted run stays recoverable even when the
directory itself was left half-built.
Every later step names a direct child of one of those two handles: the staged
files, the published migrations, atlas.sum, the journal, the commit marker,
the rollback quarantine, and orphan cleanup. Recovery runs through the same
handles, so an interrupted batch is withdrawn from the objects the run opened.
The directory pathname survives only in reported paths and error text, and no
write step resolves it a second time.
This draws the boundary against a directory replaced after the run validated
it. Replacing the pathname, or re-pointing a symlink on the way to it, no
longer selects where the run writes: a migration or an atlas.sum can land
only in the directory that was captured and verified. When the directory came
from atlas.hcl, both handles are opened through the project root, so a
replacement cannot move the write outside that root either.
Two things stay keyed to the pathname on purpose. The cross-process lock file
is created beside the directory before any handle exists, because it is
cooperative mutual exclusion between Ptah processes rather than a boundary
against a hostile writer, and every verb has to agree on its identity. The
--edit callback also receives absolute staged paths, because an external
editor cannot take a handle; the files it returns are re-validated through the
retained handle before anything is published.
Validate integrity
Section titled “Validate integrity”ptah-compat migrate validate verifies the migration directory against
atlas.sum. When --dev-url is set, Ptah first checks integrity
and then treats the dev database as scratch space: it drops user tables and
replays the migration directory to validate SQL execution semantics. If
integrity drift is found, Ptah reports the drift and does not connect to the dev
database.
A successful validation is silent, including a successful --dev-url replay.
Checksum mismatches exit 1, print Atlas-compatible recovery guidance to
stdout, and print Error: checksum mismatch to stderr. If atlas.sum is
missing, the command prints the same recovery guidance and writes
Error: checksum file not found to stderr. For added, edited, or removed
migration files, the stdout guidance includes the first mismatched atlas.sum
line, file name, and reason.
An empty migration directory is the one shape where a missing atlas.sum
is not drift: there is nothing for it to cover, so ptah-compat migrate validate
exits 0 with no output, matching the pinned Atlas community binary v1.3.0 and
matching what ptah-compat migrate apply already does on the same directory
(No migration files to execute). The moment the directory holds a migration
file the refusal returns, byte-identically. ptah-compat migrate lint --latest
follows the same rule: an empty directory selects nothing and exits 0, which
is what a repository linting its migrations in CI does before the first
migration exists. The scope selector is what makes that 0 legitimate — an
empty directory exits 0 only when --latest or --git-base was given. With
neither, the run is refused before the directory is read (see
Lint migrations below).
This compatibility behavior is scoped to the ptah-compat binary.
Native ptah migrations validate keeps Ptah’s success banner and native error
output; missing or malformed sum files remain exit-2 usage failures, and
native ptah migrations lint still refuses an empty directory with
no *.sql migration files found.
ptah-compat migrate validate \ --dir file://migrations \ --dir-format atlas \ --dev-url "sqlite://dev.db"Lint migrations
Section titled “Lint migrations”ptah-compat migrate lint \ --dir file://migrations \ --dev-url "sqlite://dev.db" \ --latest 1Both flags in that example are required, and they are two separate requirements with two separate refusals, each matching the pinned Atlas community binary v1.3.0 byte for byte:
| invocation | exit | message |
|---|---|---|
no --dev-url |
1 | required flag(s) "dev-url" not set |
no usable selector, including --latest 0 without --git-base |
1 | --latest or --git-base is required |
An argv missing both answers the --dev-url sentence, because that is the one
the pinned binary answers on the same argv.
A scope may come from the selected atlas.hcl environment instead of the
command line: a lint { latest = 1 } or a lint { git { base = … } } satisfies
the requirement with nothing spelled on the command line. An explicit
--latest 0 clears configured lint.latest but leaves an explicit or
configured Git selector eligible. With --git-base, zero follows Git selection
instead of conflicting with it. Positive --latest N remains exclusive with
Git, and an N larger than the directory analyzes every migration. The scope
refusal comes before the migration directory is read and before --dev-url is
contacted, which is the part that matters: --dev-url is scratch space and the
run cleans it, so an unscoped invocation that answered would drop tables in
a database the pinned binary never connects to.
Two environment variables relax these requirements. They relax different ones, and neither implies the other:
PTAH_ATLAS_LINT_WITHOUT_DEV_URL=1drops the dev-database requirement. Ptah’s analyzers reach a verdict from the migration files alone; the run still needs a scope.PTAH_ATLAS_LINT_ALL_VERSIONS=1drops the scope requirement and lints the whole directory. The run still needs a dev database unless the variable above is also set.
Both default to off so a pipeline written against the community CLI gets the
same refusal here that it gets there, and both are environment variables rather
than flags because the conformance cli-surface tier asserts flag parity with
the pinned binary. Native ptah migrations lint needs neither a dev database
nor a scope and is unaffected by both.
migrate lint --dev-url treats the dev database as scratch space: it drops user
tables, replays the migration directory, and then runs static lint
reporting. A docker:// value is provisioned first: the container is started,
replayed on, and removed when the command ends.
With no --format and no project template, ptah-compat migrate lint prints a
compatibility report: an Analyzing changes … header, a per-version block
listing each analyzer group’s diagnostics and mapped rule IDs, a -- ok (…)
line per version, and a summary of version statuses, semantic schema changes,
and diagnostics. For mapped Atlas diagnostics, the compatibility renderer
reproduces the measured wording, analyzer documentation links, wrapping, and
suggested-fix layout. Ptah-only diagnostics remain visibly labeled and do not
link to unproven Atlas analyzer codes. Native ptah migrations lint keeps
Ptah’s more detailed diagnostic prose and remediation guidance.
A statement affecting several objects reports per object, and the two destructive shapes are not the same:
- A
DROP TABLEnaming several tables produces one diagnostic and one suggested fix per dropped table, ordered by table name compared byte-wise, so the suggested-fix header pluralizes and the diagnostic count rises with the number of tables. - One
ALTER TABLEdropping several columns produces one diagnostic naming every dropped column in clause order, under a single suggested fix. - One
ALTER TABLEadding several non-nullable columns without a default produces one diagnostic per column, in clause order.
Native ptah migrations lint splits its findings the same way, so the two
surfaces never disagree about how many objects a statement affects. Each
per-object finding names its object in the native message, which is also what
keeps each SARIF result’s fingerprint distinct when several of them share a
rule, a file, and a line.
Which objects are under review
Section titled “Which objects are under review”The dev database is what a lint run compares against, so it also decides which
objects the run analyzes. A PostgreSQL-family --dev-url carrying
?search_path=<schema> puts exactly that one schema under review:
- an object in a different schema raises no diagnostic and counts as no schema change, because it was never part of the state the run compares against;
- an unqualified reference resolves into the reviewed schema, so it stays under review, and so does a reference written out with the reviewed schema’s own name;
- one statement is measured per object:
DROP TABLE users, other.audit_log;undersearch_path=publicreportsusersand counts one schema change; - an index is measured by the schema of the table it is on whenever the
statement names a table, because the index name is bare there:
CREATE INDEX idx ON app.users (id);undersearch_path=publiccounts no schema change and raises no diagnostic, while the same statement on apublictable counts one and raisesPG101.DROP INDEX idx ON app.t;— MySQL’s, MariaDB’s and SQL Server’s spelling — is measured the same way; - a
DROP INDEXthat names no table is measured by the qualifier on the index itself, which is the only one the statement has:DROP INDEX app.idx;undersearch_path=publiccounts no schema change and raises no diagnostic, whileDROP INDEX public.idx;and the unqualifiedDROP INDEX idx;each count one and raisePG106.
A --dev-url that names no schema puts the whole connected database under
review and filters nothing, which is also what every non-PostgreSQL dev URL
does. A search_path naming more than one schema is not a scope: it is read as
a single schema name, so it scopes nothing and every object stays under review.
An ALTER TABLE’s schema changes belong to its table’s schema, never to the
column or constraint it names, and a CREATE SCHEMA is measured against the
reviewed schema by its own name.
The scope decision is made once per statement and drives both outputs, so a
statement the scope removed contributes neither a schema change nor a
diagnostic, and one it kept can contribute either. A diagnostic that names no
object at all — the rules that report a statement rather than the objects in
it — follows the decision already taken for the statement it belongs to: dropped
when the scope removed that statement, and otherwise reported, since there is
nothing to measure a scope against and a hazard must not be silenced on an
unestablished boundary. ALTER TABLE app.users ADD CONSTRAINT … under
search_path=public therefore reports nothing, where it used to raise PG105
about a change it had already counted as zero.
A statement is removed only when every table it names is out of review, not
only the one it alters. ALTER TABLE app.child ADD CONSTRAINT c FOREIGN KEY (pid) REFERENCES public.parent (id); under search_path=public names two, and
validating that key holds a SHARE ROW EXCLUSIVE lock on public.parent for
the duration, so PG306 is reported: the hazard lands on a table the run is
responsible for. The same statement referencing app.parent reports nothing.
This is one place the two outputs deliberately describe different statements —
the constraint lands on app.child, so the reviewed schema still counts zero
changes for it. A count of zero is not a statement of safety.
Silence is not exclusion. A statement outside Ptah’s SQL grammar is left under
review whatever schema it names, because a boundary that could not be read must
not be able to drop a diagnostic. That grammar boundary is why
TRUNCATE app.users; and DROP FUNCTION app.recalc(); are reported under
search_path=public while counting no schema change.
DROP INDEX is not an example of it: the parser models the statement, so
DROP INDEX public.idx; counts one schema change,
matching the pinned community binary v1.3.0, and DROP INDEX app.idx; is
scoped out whole — no schema change and no PG106, which is also what the
community binary reports. Ptah raised PG106 for the app form before
stokaro/ptah#1296; nothing
about the reviewed schema became quieter, since the public form still raises
it.
Two DROP INDEX forms are still outside the grammar. Both are refused by the
parser rather than half-recorded, so each counts no schema change and keeps its
diagnostics under every scope:
- PostgreSQL’s multi-index
DROP INDEX a, b;, which oneDROP INDEXnode cannot hold; - SQL Server’s backward-compatible
DROP INDEX t.idx;, where the qualifier names the table rather than a schema. Reading it the way every other dialect spells the same text would scope the drop to a schema nobody wrote. Ptah renders SQL Server index drops asDROP INDEX idx ON t, which is read exactly.
Three constructs are still measured by a name that carries no schema, because
Ptah’s parser records none for them. Measured on PostgreSQL 17.10 under
search_path=public, CREATE SEQUENCE app.s; counts one schema change,
CREATE TRIGGER trg … ON app.t; counts one and raises PG308, and
CREATE POLICY p ON app.t; counts one — where the pinned community binary
v1.3.0 counts no schema change and raises no diagnostic for all three. They
over-report rather than under-report, and each is a warning at exit 0, so a
scoped run is never told less than the truth about its database. They are listed
here rather than left to be discovered.
The boundary applies to ptah-compat migrate lint only. Native
ptah migrations lint keeps every object under review, whatever the dev URL
selects, so the two surfaces deliberately disagree about scope.
The reason the boundary exists on the compatibility surface is that the tool it
replaces reviews only what the dev URL covers, and matching that is the whole
point of the surface. The reason it does not exist natively is that the
justification for it — an object outside the dev URL’s reach was never in the
before-state the run compares against — describes a diff-based analyzer, and
Ptah’s linter reads SQL text. That is why it reports TRUNCATE and
DROP SCHEMA, neither of which produces a diff. Scoped natively,
DROP TABLE app."Users", app.audit_log; under search_path=public reports
nothing and exits 0, on the one surface where no other tool can be consulted
about whether that is right.
Renames
Section titled “Renames”A rename retires one logical name and introduces another. The two surfaces describe that single event at different altitudes:
ptah-compat migrate lintreports it as a destructive change to the retired name —DS102forALTER TABLE … RENAME TO,RENAME TABLE …and MySQL’s bareALTER TABLE … RENAME new_name;DS103forRENAME COLUMNand its form without theCOLUMNkeyword. The diagnostic names the old name rather than the new one, carries the matching pre-migration-check suggested fix, is error-severity, and exits 1.- Native
ptah migrations lintreportsBC101, which explains the operational hazard and prescribes add-new/backfill/drop-old across releases. It stays a warning there.
Exactly one of the two is emitted per rename, so neither surface reports one statement twice.
A statement renaming several objects follows the same two shapes the destructive
drops do. One ALTER TABLE renaming several columns — the MySQL multi-clause
form — produces one diagnostic naming every renamed column in clause order,
under a single suggested fix. One RENAME TABLE naming several pairs produces
one diagnostic and one suggested fix per renamed table, ordered by table name
compared byte-wise. Ordering is per statement: consecutive ALTER TABLE … RENAME TO statements report in the order they are written.
Renaming an object the same migration file created reports nothing on either surface: no deployed application version ever saw a name this migration itself introduced.
Because the retired name is a destructive change, -- atlas:nolint destructive
suppresses a rename on the compatibility surface and -- atlas:nolint incompatible does not.
Index, key and constraint renames are not reported at all: deployed application code does not name them.
On the compatibility surface a rename also reports the column it introduces.
Renaming a NOT NULL column with no DEFAULT produces a second diagnostic —
MF103, “adding a non-nullable column will fail in case the table is not
empty” — naming the new column and the retired column’s type. A RENAME COLUMN
statement carries neither that type nor its nullability, so this one is not read
from the migration text: ptah-compat migrate lint reads the schema state the
version starts from off the --dev-url database during the replay it already
performs, before the version runs and while the retired column still exists. A
run without --dev-url reports the retirement alone.
Three consequences follow from where the facts come from:
- The type is spelled the way the database canonicalizes it, not the way the
migration writes it:
intreports asintegerandvarchar(20)ascharacter varying(20). - A retired column that is nullable, or that carries a
DEFAULT, has no such diagnostic — the introduced column cannot fail on existing rows. - A type whose diagnostic spelling Ptah has not measured keeps the diagnostic but prints Ptah’s own labeled wording for it rather than a guess at the other tool’s.
The two halves belong to different analyzers, so -- atlas:nolint destructive
silences the retirement and leaves the addition, and -- atlas:nolint data_depend does the reverse.
Native ptah migrations lint reports no addition. It models a rename as a
rename, and a rename does not fail on a populated table, so BC101 stays its
single finding.
The report is written to stdout even when findings fail, and error-severity
findings still exit with code 1. The native ptah migrations lint output is
unchanged. Custom output is selected by --format, by format.migrate.lint,
or by Atlas’s lint { log = "…" } template; an explicit CLI --format wins
over a project template, and a selected --env lint.log overrides a global
one.
The command captures the migration directory once before checking atlas.sum,
selecting --latest versions, replaying migrations, and rendering reports.
Checksum status, findings, statement metadata, and formatted output therefore
describe the same immutable inputs.
The Replay Migration Files step reports the number of semantic schema changes
the selected migrations express, recovered from Ptah’s dialect-aware parse of
their DDL — the same parser the replay and planner use — not a count of
statements or files. One statement can contribute zero changes (an operational
INSERT/SELECT or a construct outside the DDL grammar), exactly one (a single
CREATE), or several (a multi-action ALTER TABLE, or a DROP TABLE naming
several tables), so this change count and the new-migration-file count differ in
general. A table rename counts as two changes — the old name stops naming an
object and the new one starts — while a column rename counts as one, because the
table it modifies stays the same object.
Ptah also validates and fully loads the migration provider, including Atlas templates, before dropping any objects from the dev database. A malformed migration directory therefore leaves the existing dev database state intact; cleanup starts only after the replay plan is valid.
For the code-by-code status of the analyzer checks Atlas marks as Pro against Ptah’s lint rules, see Lint rules.
A statement-local -- atlas:nolint <selector> suppresses the statement
directly below it. A blank line between the directive and the statement
detaches the two: the directive then suppresses nothing, exactly as the
community binary treats it.
The whole-file header form — a first nonempty -- atlas:nolint <selector>
line followed by a blank line — is enabled only under the ptah-compat migrate lint compatibility profile. A bare file header ignores the file completely, so
it is absent from .Files and per-file analysis steps.
Supported analyzer selectors are destructive, data_depend,
concurrent_index, incompatible, and nestedtx. They name rule families, so
they mean the same thing on both surfaces.
A code selector names the code the running surface prints: every code
ptah-compat migrate lint emits is suppressed by that code, and every code
ptah migrations lint emits is suppressed by that code. The three codes the
compatibility surface renames are the only place the two differ — a column drop
prints DS102 natively and DS103 on the compatibility surface, so -- atlas:nolint DS103 silences it there and -- atlas:nolint DS102 silences it
natively. Where two native rules share one printed code, the selector reaches
both: -- atlas:nolint DS103 silences a DROP COLUMN and a column type change
alike.
A code selector matches one code exactly and never widens into a family, on
both surfaces: -- atlas:nolint DS and -- atlas:nolint D suppress nothing,
while the native -- ptah:nolint DS still silences the whole family. An
unrecognized
selector is accepted and suppresses nothing, without a warning — the pinned
community binary behaves the same way, and matching it was chosen over
diagnosing the typo. Ptah’s own .ptah-lint.yaml disabled-rules remains
strict and rejects a selector matching no registered rule.
Migrate-up safety keeps its native directive semantics: the whole-file Atlas header never reopens the apply-time destructive gate.
A migration directory held in a registry
Section titled “A migration directory held in a registry”--dir atlas://<repository> reads the directory from an OCI registry instead of
from disk. The reference carries no registry host, so it resolves against the
namespace PTAH_ATLAS_REGISTRY names — the same resolution an atlas.hcl
migration.dir uses:
export PTAH_ATLAS_REGISTRY=ghcr.io/acme
ptah-compat migrate status --dir "atlas://app?tag=prod" --url "$DATABASE_URL"ptah-compat migrate apply --dir "atlas://app?tag=prod" --url "$DATABASE_URL"ptah-compat migrate validate --dir "atlas://app?tag=prod"ptah-compat migrate lint --dir "atlas://app?tag=prod" --dev-url "$DEV_URL" --latest 1tag selects a moving tag and defaults to latest; version selects an
immutable one. Naming both is refused, because a tag moves and a version does
not.
The directory is read-only. migrate hash, migrate new and migrate diff
refuse against it and name the reference you typed:
Error: atlas migrate hash writes to the migration directory, and atlas://app?tag=prod came from aregistry, which Ptah reads and does not write back to; write to a local directory and publish itwith `ptah migrations push`That refusal does not need a reachable registry, and neither does the one for a missing namespace — nothing is fetched until a verb reads the directory.
Publish the directory with ptah migrations push; the artifact is an ordinary
OCI one, pulled with ordinary registry credentials, and no hosted service is in
the path.
Metadata commands and directory formats
Section titled “Metadata commands and directory formats”Atlas-compatible migration metadata commands default to Atlas directory
format. ptah-compat migrate hash, lint, new, set, status, and
validate register --dir-format with Atlas’s default value atlas.
All six accept every Atlas source layout — golang-migrate, goose, flyway,
liquibase, and dbmate — under either spelling Atlas accepts, and produce the
same atlas.sum from both:
ptah-compat migrate hash --dir "file://migrations?format=goose"ptah-compat migrate hash --dir file://migrations --dir-format gooseEach layout covers a different set of source files, matching Atlas; see the compat command reference for the per-layout rules and for the inputs that stay refused.
When both spellings are given, the ?format= query wins, which is what Atlas
does. Values are matched verbatim on every CE-comparable path that resolves a
layout — the six above plus diff and import: --dir-format ATLAS and
--dir-format " atlas " are refused rather than normalized, and an empty value
selects the Atlas layout. migrate import resolved its own source format until
#1235 cell 9.8 was closed as a
class; it accepted FLYWAY and " flyway " and read ?format= with an empty
value as no selection, all three of which Atlas refuses.
A rejected value is refused with the measured CE wording — unknown dir format "bogus" — on all nine CE-comparable paths, apply included through its
?format= query. Fuller-surface commands such as checkpoint, test, edit,
rebase, and rm keep Ptah’s longer diagnostic, which names the accepted
layouts.
format is the only --dir query key that selects anything. On the eight verbs
that accept a --dir query — apply, diff, hash, lint, new, set,
status and validate — any other key is ignored, as Atlas ignores it, and
named on standard error so a misspelled ?fromat=goose does not quietly read
the directory in the Atlas layout. The exit code and standard output are
unchanged. Set PTAH_STRICT_DIR_QUERY=1 to refuse an unrecognized key instead.
migrate checkpoint, down, edit, rebase, rm and test register --dir
as well and refuse any query on it, so the note never appears there; see
the compat command reference for that split.
migrate new writes the selected layout rather than only reading it. The
created file names and their contents follow the source tool’s own convention,
and atlas.sum is rewritten over the set that layout covers:
--dir-format |
files created | covered by atlas.sum |
|---|---|---|
atlas |
<version>_<name>.sql, empty |
the file |
golang-migrate |
<version>_<name>.up.sql and .down.sql, both empty |
the .up.sql only |
flyway |
V<version>__<name>.sql and U<version>__<name>.sql, both empty |
the V file only |
goose |
<version>_<name>.sql holding -- +goose Up / -- +goose Down |
the file |
dbmate |
<version>_<name>.sql holding -- migrate:up / -- migrate:down |
the file |
liquibase |
<version>_<name>.sql holding --liquibase formatted sql |
the file |
Two inputs are refused on a non-atlas layout. A migration name is required,
because a file named by the version alone is one Ptah’s own migrate apply
cannot read back on four of the five layouts. --edit is refused, which is what
Atlas does for a non-Atlas directory as well.
The <version> in every row of that table is the UTC yyyyMMddHHmmss second the
command ran in, on every layout, and it is the same value migrate diff stamps.
migrate new and migrate diff step forward only to get past a version the
directory already holds — never to get past the newest one. A directory whose
newest migration is dated in the future therefore receives today’s version,
sorting below that migration, which is what Atlas was measured to do and what
both of those verbs now do
(#938). migrate new used to bump
to newest + 1 instead, so the same directory could hold both shapes.
Two verbs bump past the newest migration instead, and each has a reason it must.
migrate checkpoint’s version has to outrank every migration it squashes, or a
fresh database bootstraps from the checkpoint and then applies a migration whose
SQL that checkpoint body already contains. migrate rebase moves a migration to
the END of history, so a version sorting below the newest one would not move it
at all. Into a directory holding 20200101000000_users.sql and
29991231235959_archived.sql, checkpoint wrote 30000101000000_squash.sql;
migrate apply against an empty database then reported 1 pending migrations
and produced both tables; and migrate new into that same directory took today’s
second, sorting below the 2999 migration. One binary, two rules, on the one
directory shape that separates them.
Whichever rule applies, the step lands on a second that exists. 29991231235959
plus one as an integer is 29991231235960 — sixty seconds past the minute,
which time.Parse refuses under the very layout the rest of the name uses — and
that is what the bump used to write, 29991231235961 on the run after it. Every
Atlas-layout version migrate new, migrate diff, migrate checkpoint and
migrate rebase write now reads back as the UTC second it looks like, so the
bump beside a 29991231235959 neighbor lands on 30000101000000 and then on
30000101000001, and two migrations created inside the same :59 second land on
the next minute rather than on a sixtieth one.
migrate rebase was the last of those four to stamp that shape. It took a Unix
epoch for every layout, so moving a migration to the end of an Atlas directory
numbered 1_init.sql, 2_second.sql wrote a ten-digit 1786268355_init.sql
beside fourteen-digit neighbors, and beside a 29991231235959 migration it wrote
29991231235960 and then 29991231235961. It now reads the same UTC clock the
other three do for an Atlas directory, and keeps the epoch for the paired ptah
layout described next, whose names cannot carry a fourteen-digit version at all.
atlas migrate rebase --help on the community binary prints 'atlas migrate rebase' is not supported by the community version. and exits 0, so there is no
measured stamping behavior to match here — the same position migrate checkpoint is in.
Native ptah migrations create --dir-format ptah keeps the paired layout’s own
rule, the clock or newest + 1, whichever is greater: nothing outside Ptah reads
that layout, so no compatibility argument moves it, and a version that only goes
forwards is the safer of the two. Its versions are ten digits, so they never
look like a timestamp and the calendar rule above does not apply to them. What
it will not do is walk past 9999999999, the largest version its fixed-width
NNNNNNNNNN name can hold. It refuses instead, because the eleven-digit name it
used to write was one the reader silently skipped.
migrate diff writes every selected layout under both spellings. It writes the
forward and rollback pair for golang-migrate and flyway, one file with both
directive sections for goose and dbmate, and a Liquibase changeset with
--rollback: lines. The refreshed atlas.sum covers the selected layout’s file
set. migrate apply registers no --dir-format at all, matching Atlas, and
selects a converted source directory through ?format= on --dir.
Goose writes -- +goose NO TRANSACTION when either direction requires
no-transaction execution; its directive governs the whole file and therefore
both directions. golang-migrate, Flyway, dbmate, and Liquibase remain
fail-closed for those plans because their safe transaction metadata has not
been proven. Their refusal happens before any migration file or atlas.sum is
written.
ptah-compat migrate set [version] moves Atlas revision history to the
selected boundary without executing migration SQL. It preserves existing clean
rows through the target, inserts missing rows as manually set, keeps dirty-row
diagnostics with the combined applied and manually-set type, and removes rows
above the target.
Which verbs enforce atlas.sum
Section titled “Which verbs enforce atlas.sum”apply, status, set, validate, new and diff all verify the
directory’s integrity file before doing anything else, with the same output on
all six (#974,
#1086). The refusal precedes the
database connection, so an unreachable --url or --dev-url does not hide it;
on set it precedes the positional-version check too, and on diff it precedes
--to and --dev-url being required at all. The empty-directory and
non-SQL-directory exemptions described under
Apply a migration directory apply identically,
so a CI bootstrap that creates an empty migrations/ keeps working.
new and diff are the two that WRITE, so for them the gate is a preflight
rather than a check alongside the work: nothing is created, and no atlas.sum
is rewritten, on a directory the gate refuses. A --dir naming a directory that
does not exist yet is not a checksum error on either tool — both verbs create
it, which is how the first migration of a project gets written.
The six shared scheme-hint consumers — hash, validate, status, lint,
new, and diff — require their command-line --dir to name a scheme:
ptah-compat migrate new add_users --dir migrations# Error: missing scheme for dir url. Did you mean "file://migrations"?The stderr line ends with the bytes 20 0a: one ASCII space followed by the
line feed. Nothing is created. The requirement applies to PTAH_DIR as well as
the flag.
A directory named by atlas.hcl migration.dir still accepts a bare path and
remains looser than Atlas, as tracked in
#1186. PTAH_MIGRATIONS_DIR,
which is the native --migrations-dir under its environment name, still takes a
plain path.
A migration name cannot contain a path separator
Section titled “A migration name cannot contain a path separator”The name becomes part of the file name, so a / in it selects a directory that
is not there. Both writing verbs refuse it and write nothing
(#1231):
ptah-compat migrate new "sub/dir_name" --dir file://migrations# Error: atlas migrate new "sub/dir_name": migration name must be a single file# name element, without a path separatorThe community CLI refuses the same input, with the raw open …: no such file or directory of the write it did not expect to fail; Ptah names the rule instead,
in the same words the foreign-layout path has always used. Nothing else about a
name is refused on an Atlas-layout directory: a space, a backslash and .. are
all accepted, because they are accepted there.
The refusal lands where the file would be written, which matters on
migrate diff: a diff that finds no changes writes nothing and never reaches
the name, so it still exits 0 — the same as the community CLI.
lint deliberately does not enforce it, but only for a missing integrity
file: linting a directory that has never been hashed is how you inspect one
before adopting it. It does not tolerate drift. On a hashed directory whose
migration was edited, added to, or removed from, both lint implementations
exit 1 on the checksum mismatch, so this is not a route for inspecting a
directory that has already drifted.
down enforces it too, and did not always. It reads a native Atlas directory
and executes rollback SQL from it, so a hashed directory whose migration was
edited is now refused there exactly as it is on status. Before that, status
exited 1 on such a directory while down reported normally and exited 0 —
integrity verification guarding the constructive direction and not the
destructive one, which is backwards, because down is the direction where the
result cannot be inspected afterwards.
The refusal is not a parity claim. The community binary refuses migrate down
outright, so there is no behavior to match: gating it cannot exit 0 where that
binary exits 1, and “matching is the floor, not the ceiling” is what leaves room
for the refusal. The two paths through the verb refuse with different text for
that reason. The default forward inherits the native refusal from
ptah migrations down and prints ptah’s own drift report; --format runs the
shared optional-hash gate and renders the Atlas-format rollback report. An
unhashed directory remains usable, a drifted hashed directory is refused, and
PTAH_ALLOW_UNVERIFIED_MIGRATION_DIR=1 permits the same explicit recovery as
native down, with a warning. Strict CE policy rejects that extension variable
and retains the CE refusal surface.
checkpoint also enforces it now, on the native verb the compat surface
forwards to. It was the worst of the ungated set: it replays the whole history
onto a shadow database and writes what it observed there into a new migration
under a fresh checksum, so drift was not merely executed but laundered into a
directory that verifies clean afterwards. The command now verifies and replays
one immutable snapshot, then its rooted writer refuses publication if the
directory no longer matches that snapshot before adding the checkpoint and
refreshing atlas.sum.
rm, rebase and edit remain divergent — they write an atlas.sum over a
directory whose previous contents were never verified, turning drift into
apparent cleanliness. All three are verbs the community binary refuses outright,
so like down they have no behavior to match; they are listed because the
hazard is the same one, not because a comparison exists. No issue tracks closing
that gap yet. new and diff used to be on this list and were
gated by #1086; the predicate
they now share is the one described above, so the remaining four need a decision
about the missing-atlas.sum case rather than new machinery.
With --env, it reads env.url, migration.dir, and
migration.revisions_schema from atlas.hcl; explicit --url, --dir, and
--revisions-schema flags keep CLI precedence. ptah-compat migrate status
also accepts --revisions-schema and runs against Atlas revision-table
metadata.
A pre-apply check sequence for CI looks like:
ptah-compat migrate hash --dir file://migrationsptah-compat migrate new add_users --dir file://migrationsptah-compat migrate validate --dir file://migrations --dev-url "sqlite://dev.db"ptah-compat migrate status --url "$DATABASE_URL" --dir file://migrationsImport from other tools
Section titled “Import from other tools”ptah-compat migrate import converts a local file:// migration directory from
an Atlas-supported format into a separate Atlas single-file directory and
writes atlas.sum:
ptah-compat migrate import \ --from "file://flyway?format=flyway" \ --to "file://migrations"A successful compatibility import is silent. Inspect the destination directory
and its atlas.sum to see what was written, rather than reading a progress
message off stdout. Rejections stay loud on stderr.
The command is intentionally fail-closed: use a destination directory different
from the source directory, and start with a destination that does not already
contain .sql migration files or atlas.sum. Flyway repeatable migrations are
not in that list: they are converted onto a reserved version slot above every
versioned migration, and the destination file name carries that slot rather than
an R suffix, so the imported directory keeps one-time migration semantics
instead of Flyway-style reapply semantics.
For Liquibase formatted SQL, a directory containing only numbered SQL names
keeps the one-source-file-to-one-Atlas-file conversion. If any covered SQL file
has a conventional name such as changelog.sql, the importer parses the entire
covered SQL set as formatted SQL, orders files lexically and changesets by
appearance, and writes global versions 1..N with the changeset author and ID
in each file name. Version tokens are left-padded to the digit width of N, so
an 11-changeset stream is named 01_... through 11_... and atlas.sum keeps
the same order as Liquibase. A headerless or malformed member refuses the whole
import before the destination is created. The resulting numeric Atlas directory
and its atlas.sum validate and apply under both Ptah and Atlas CE. Liquibase
XML, YAML, and JSON changelogs remain unsupported.
The source directory’s atlas.sum is verified first. If the source carries
one, it must cover the source before anything is converted, and the source
checksum is checked ahead of the destination rules above — a tampered source is
refused whatever the destination looks like, and no destination directory is
created. If the source carries no atlas.sum at all, the import proceeds: a
directory another tool wrote has never been hashed, and importing it is what
this verb is for. See
Integrity and safety.
The native ptah migrations import converts the same source formats into
Ptah-native migrations instead; see
Import from another tool.
Format template fields
Section titled “Format template fields”ptah-compat migrate apply --format
- Top level:
.Driver,.URL,.Dir,.Env,.Pending,.Applied,.Current,.Target,.Start,.End,.Error, and JSON.Messagefor successful or no-op reports. - Each
.Pendingand.Appliedentry:.Name,.Version,.Description. Applied entries also expose.Applied,.Skipped,.Checks, and statement.Error.
ptah-compat migrate diff --format
.Changes,.MarshalSQL, plus thesqlhelper for generated migration SQL.
ptah-compat migrate lint --format
- Top level:
.Env.Driver,.Env.URL,.Env.Dir,.Steps, and.Files. - Each step entry:
.Name,.Text,.Error, and.Result. - Each file entry:
.Name,.Text,.Error, and.Findings.
ptah-compat migrate status --format
- Top level:
.Env.Driver,.Env.URL,.Env.Dir,.Available,.Applied,.Pending,.Current,.Next, and.Status. - Each available and pending migration file entry:
.Name,.Version, and.Description. - Each applied revision entry:
.Version,.Description,.Type,.Applied,.Total,.ExecutedAt,.ExecutionTime,.Error,.ErrorStmt, and.OperatorVersion.
ptah-compat migrate down --format
.Env,.Planned,.Reverted,.Current,.Target,.Total,.Start,.End, and.Error.
The shared report shape and URL redaction rules are in Atlas-compatible output and redaction.
Next steps
Section titled “Next steps”- Inspecting, diffing, or applying schemas instead of migrations: Atlas schema commands.
- Deciding whether the native lifecycle fits better: Versioned migrations.
- Checking what this surface does and does not prove: Conformance.