Atlas project config
Ptah can read a strict subset of Atlas project configuration from atlas.hcl
and translate it into Ptah’s project config IR. This is command configuration,
not schema HCL input. For schema HCL, see HCL schema.
ptah-compat <command> ... invocations on this page run the separate
ptah-compat drop-in binary; see the
Atlas compatibility overview.
Supported blocks
Section titled “Supported blocks”Ptah accepts these local configuration blocks:
- top-level
variable - top-level
locals data "hcl_schema"for local schema file datadata "external_schema"for program-generated desired statedata "sql"for a one-column database querydata "external"for direct program outputdata "runtimevar"for Go CDK runtime-variable URLsdata "template_dir"for rendered migration directoriesenvblocks, with either one label or no label- top-level and env-local
lint - top-level
exporterfor named output templates - env-local
schema,migration,format, anddiff
Referenced Atlas Cloud and remote-directory sources, registry constructs, and unsupported data-source types fail explicitly.
Example
Section titled “Example”lint { git { base = "origin/master" dir = "." }}
env "local" { url = "postgres://user:pass@localhost:5432/app?sslmode=disable" dev = "postgres://user:pass@localhost:5432/app_shadow?sslmode=disable" src = ["file://schema.hcl"] exclude = ["tmp_*"]
schema { src = ["file://schema.hcl"] mode { funcs = false permissions = false roles = false triggers = false } }
migration { dir = "file://migrations" format = "atlas" revisions_schema = "atlas" lock_timeout = "3s" exec_order = "linear" tx_mode = "file" }
lint { latest = 5 destructive { error = false } }
format { schema { inspect = "{{ json . }}" apply = "{{ sql . \" \" }}" diff = "{{ sql . \"\" }}" } migrate { apply = "{{ json . }}" diff = "{{ sql . \"\" }}" } }
diff { skip { drop_table = true } concurrent_index { create = true } }}Mapping to Ptah behavior
Section titled “Mapping to Ptah behavior”| Atlas setting | Ptah behavior |
|---|---|
env.url |
Default database URL for compatible schema and migration commands. |
env.dev |
Default disposable replay database URL. Rollback verification resets it and requires it to identify a different database from env.url. |
env.src |
Default desired schema source for schema apply. |
env.schema.src |
Default desired schema source for schema apply, schema diff, and migrate diff. |
env.schema.mode.<object> |
Default object-kind exclusions for supported schema object kinds. |
env.exclude |
Default Atlas-style resource exclusion filters. |
env.schemas |
Restricts the schema universe that compatible schema inspect, schema apply, schema diff, schema plan, and migrate diff operate over. |
migration.baseline |
Migration version migrate apply marks applied before running the pending ones; the config spelling of its --baseline flag, which still wins when passed. |
migration.dir |
Default migration directory. |
migration.format |
Default migration directory format where supported; safety gate for migrate apply. |
migration.revisions_schema |
Default revision metadata schema. |
migration.lock_timeout |
Default migration lock timeout. |
migration.exec_order |
Default migration execution order. |
migration.tx_mode |
Default transaction mode for compatible apply paths. |
lint.latest |
Latest-N migration lint selection. |
lint.git.base |
Git base for migration lint selection. |
lint.git.dir |
Git working directory for migration lint selection. |
lint.<analyzer>.error |
Severity mapping for supported Ptah lint rule families. |
lint.log |
Atlas Go-template that renders migrate lint output; shares the format.migrate.lint IR and precedence. |
format.schema.inspect |
Default schema inspect --format. |
format.schema.apply |
Default schema apply --format. |
format.schema.diff |
Default schema diff --format. |
format.migrate.apply |
Default migrate apply --format. |
format.migrate.diff |
Default migrate diff --format. |
format.migrate.lint |
Default migrate lint --format. |
format.migrate.status |
Default migrate status --format. |
diff.skip.drop_table |
Suppresses table drops in supported local diff/apply plans. |
diff.concurrent_index.create |
Requests PostgreSQL concurrent index creation where transaction mode allows it. |
diff.concurrent_index.drop |
Requests PostgreSQL DROP INDEX CONCURRENTLY for standalone index removals. |
migration.baseline
Section titled “migration.baseline”baseline names the migration version migrate apply marks as already applied
before it runs the pending ones — the atlas.hcl spelling of the command’s
--baseline flag. The flag still wins when it is passed, so a project file sets
the default and a caller can override it for one run.
The value is a version, resolved against the migration directory: one that the
directory does not hold fails with
baseline version "20200101000000" not found. baseline = null and
baseline = "" are both read as “no baseline”, which leaves every migration
pending. A value that is not a string is refused with
atlas.hcl "baseline" at atlas.hcl:5 must be a string.
env.schemas
Section titled “env.schemas”schemas names the schemas the environment operates over. It must be a list of
strings; a bare string, an object, or a list holding anything but strings is
refused with its source location, which is what Atlas CE does with the same
file.
The list restricts the schema universe rather than filtering the output, so a schema it does not name is not read at all:
env "local" { url = getenv("DATABASE_URL") schemas = ["app", "audit"]}Semantics, all measured against a PostgreSQL database holding schemas one,
two, and public:
| Value | Schemas described |
|---|---|
schemas = ["one"] |
one |
schemas = ["one", "two"] |
one and two |
schemas = ["nosuchschema"] |
none; the command still exits 0 |
schemas = [] |
all of them — an empty list is not a selection |
| attribute absent | all of them |
--schema outranks the attribute outright and does not intersect with it: with
schemas = ["one"] in the file, --schema two describes two alone.
Restricting the universe means Ptah describes less than it did before the
attribute was honored. Set PTAH_ATLAS_IGNORE_ENV_SCHEMAS=1 to keep the
realm-wide description; the attribute is then reported as having no effect, as
any other tolerated name is. The variable governs the selection only — a value
the field cannot hold is refused with it set exactly as it is without it,
because Atlas CE refuses that file and compatibility never exits 0 where the
community binary exits 1.
A present value the variable cannot hold is refused on every Atlas
project-config load or parse, before the document is read: whether the file is
absent, whether it parses at all, and whether the selected environment spells
schemas make no difference to the diagnostic. The variable is a property of
the environment, so the answer may not depend on the file under it.
PTAH_ATLAS_STRICT_COMPAT=1 rejects an enabled opt-out because restoring the
realm-wide Ptah view is deliberately outside the CE-only profile.
format.schema.inspect follows the Atlas-compatible command’s template
semantics. The exact bare values "hcl", "sql", and "json" write those
literal bytes with no line feed. Surrounding whitespace is also preserved; for
example, " sql " writes hex 20 73 71 6c 20. Use "{{ hcl . }}",
"{{ sql . }}", or "{{ json . }}" to render the inspected schema. Native
ptah schema inspect --format hcl|sql|json keeps its rendered shorthands.
migration.tx_mode accepts file, all, or none. A migration’s leading
atlas:txmode file or atlas:txmode none header overrides global file or
none; global all rejects every explicit file mode before the selected batch
starts. An explicit file mode under global none restores a per-file
transaction and permits migration timeouts. The header is significant only in
the unbroken run of line comments that begins on line 1; a directive outside
that block is ignored, as it is on Atlas CE, and reported at WARN rather than
dropped in silence.
Project config precedence is explicit CLI flags, environment variables,
atlas.hcl, ptah.yaml, then built-in defaults. Project-file merging
preserves source presence. For a supported field, an explicitly present value
replaces the lower-precedence value instead of being treated as absent. This
includes an empty string, zero, false, or an empty list when the field
accepts that type.
After project sources are merged, a command applies its built-in default only when a field is absent. An explicitly present empty or zero value instead reaches normal validation. Fields that do not accept empty values, including Atlas format templates, fail during parsing or command validation.
When a forwarded native implementation also consumes project configuration,
the adapter passes this merged snapshot instead of reopening either project
file. This currently applies to migrate down; other adapters map evaluated
Atlas project values to explicit native command arguments.
env.exclude and disabled env.schema.mode values compose with the
schema apply/schema diff positive selection flags in a fixed order:
--schema names define the universe for schema-owned resources, --include
selectors pick resources, and the configured exclusions subtract last, exactly
like CLI --exclude. Database-wide extensions skip the schema ownership
restriction. An extension-only include filters their identities; a matching
non-extension resource carries all extensions as support even beside extension
selectors without treating an omitted desired extension as a removal.
Schema-only and extension-only scopes remain authoritative. Exclusions still
subtract afterward. See
Scope the comparison with --schema and --include.
env.schema.mode.sensitive accepts Atlas’s DENY and ALLOW values. Both are
no-ops because Ptah does not emit sensitive values through the supported local
workflows. Ptah records either spelling as an ignored compatibility construct
and warns that it has no effect.
Ptah accepts Atlas’s atlas, golang-migrate, goose, flyway,
liquibase, and dbmate values while evaluating atlas.hcl, and
ptah-compat migrate apply executes all of them.
The native atlas format is read from disk unchanged (preserving atlas.sum
verification, down migrations, and Atlas R/<number>R repeatable migration
tokens); every other format is converted in memory to Atlas single-file,
up-only migrations, so apply runs only the source tool’s forward (up) SQL.
Flyway repeatables in converted directories are treated as one-time migrations.
Unknown formats still fail before the target database is opened.
An explicit ?format= query on the effective directory URL, whether declared
by migration.dir or passed with CLI --dir, overrides this project default,
matching Atlas’s URL precedence. An empty query value selects the native
atlas format.
# Apply a Goose directory directly.ptah-compat migrate apply --env local \ --dir "file://migrations?format=goose"Apply and ptah-compat migrate import share the format parsers and up/down
semantics. Conventional Liquibase import adds a persistence adapter that emits
one numeric Atlas file per changeset; direct apply retains its numbered-file
requirement and source-file boundary. See
stokaro/ptah#742.
env.src and env.schema.src provide local schema-file defaults for
schema apply and schema diff. migrate diff resolves the same defaults
through its typed desired-state resolver, so they can contain local schema
files or one directly connectable database URL. Plain local schema paths and
relative file:// schema URLs declared in atlas.hcl resolve relative to the
directory containing that atlas.hcl file, not the process working directory.
Explicit CLI --to and --from values keep CLI semantics and resolve relative
to the process working directory unless they are absolute.
The Atlas-compatible schema commands and migrate diff also accept explicit
env:// references. env://src and env://schema.src expand the selected
environment’s schema sources through the typed desired-schema resolver, so the
expanded value can be a supported local file, database URL, or declared
external schema program. env://url and env://dev resolve the corresponding
database URL; env://migration.dir resolves the configured local migration
directory. Nested env:// references fail explicitly.
Functions
Section titled “Functions”atlas.hcl is evaluated against the same function set as an HCL schema file —
join, upper, replace, substr, sort, format, jsonencode, try,
can and the rest. An expression that works in a schema file works here:
variable "schemas" { type = list(string) default = ["public", "app"]}
env "local" { url = getenv("DATABASE_URL") exclude = [join(",", var.schemas)]}Three functions mean something different here than in a schema file, because
their answer depends on where the file being evaluated lives: file and
fileset read the directory holding atlas.hcl, and getenv reads the
process environment. Everything else is shared.
print is not available here, unlike in a schema file. It returns its argument
and writes the value to stdout, and atlas.hcl is the one place a
sensitive = true variable exists — so print(var.token) would put a
credential in the command’s output and in CI logs.
A sensitive = true variable is protected on both error paths, and the two
differ on purpose:
- an expression that fails to evaluate has its diagnostic withheld entirely, whatever functions the value passed through;
- a value that evaluates and then fails a rule is scrubbed when the
expression names the variable directly, so the reason survives
(
unsupported URL scheme: (sensitive value)), and withheld when the value was derived —upper(var.token)puts bytes in the message that replacing the original value cannot find.
A non-sensitive expression keeps its full diagnostic either way.
Reading files with file() and fileset()
Section titled “Reading files with file() and fileset()”file("path") inlines a file’s contents into a config value, and
fileset("glob") expands to a list of paths. Both resolve relative to the
directory holding atlas.hcl, and both are confined to it. These are refused,
with the reason named:
| Argument | Refused because |
|---|---|
file("/run/secrets/db") |
absolute path |
file("../secrets/db") |
parent traversal |
file("link.txt") where link.txt points outside the directory |
symbolic link leaving the directory |
fileset("*.hcl") where one match points outside the directory |
the whole call fails; escaping entries are never dropped silently |
A symbolic link that stays inside the directory is read normally, including one that walks up and back down inside it. The rule is about where the path goes, not about links.
Pass a value that genuinely lives elsewhere through the environment instead:
env "local" { url = getenv("DATABASE_URL")}This is stricter than the community binary, deliberately. Measured against the
pinned community v1.3.0 build: an atlas.hcl calling file("/etc/passwd") or
file("../../../../etc/passwd") exits 0 there with the file read, and the
contents reach an observable place — a database URL, an error message on
standard error. An atlas.hcl is repository-controlled and evaluated before
anything is applied, so matching that would hand any config author an
arbitrary-file read on the machine running the migration. Both measured runs
exit 1, so no working configuration changes; Ptah’s refusal names its reason.
See
stokaro/ptah#1042.
The confinement covers what file() and fileset() read. It is not a claim
that atlas.hcl cannot name a path outside its directory at all: a schema
source such as src = "../shared/schema.hcl" is a path the author points the
tool at deliberately, and it still resolves.
Local schema data source
Section titled “Local schema data source”data "hcl_schema" names local schema files and exposes them as
data.hcl_schema.<name>.url — one file:// URL for path, a list of them for
paths.
Its paths resolve relative to the directory holding atlas.hcl but are not
confined to it: path = "../shared/schema.hcl" is a file the author points the
tool at deliberately, and it resolves. Two kinds of value are refused, and each
names its own rule rather than blaming the path key, which is supported:
| Value | Refusal |
|---|---|
path = "/etc/absolute.hcl" |
atlas.hcl "path" at atlas.hcl:2: absolute paths are not supported: /etc/absolute.hcl: give a path relative to the directory holding atlas.hcl |
path = "s3://bucket/x.hcl" |
atlas.hcl "path" at atlas.hcl:2: unsupported URL scheme: s3://bucket/x.hcl |
paths is refused the same way, naming paths. An attribute the data source
does not have is a different failure and keeps the construct wording:
unsupported atlas.hcl construct "frobnicate".
vars is scoped to the files the data source selects
Section titled “vars is scoped to the files the data source selects”vars supplies values for the variable blocks of the schema files this data
source names, and only those files:
data "hcl_schema" "app" { paths = ["schema.hcl"] vars = { tenant = "acme" }}
env "local" { url = "sqlite://app.db" src = data.hcl_schema.app.url}The scoping is the whole point, and it runs in both directions. Another data
source’s vars never reach these files, and the run’s global --var does not
cross the boundary either — a data source that declares no vars at all still
closes it, so a schema file behind one with a required variable and no value
fails rather than picking the flag up. A file named directly, as
src = "file://schema.hcl", is outside every data source and does take --var.
The boundary belongs to the source the env selected, not to the file. A desired
schema the operator names on the command line — --to, --from, --file, or
schema test’s --url — keeps --var even when it is the very file a data
source of the loaded env selects. For example, schema apply --env local --to file://schema.hcl reads --var tenant=… and fails without one.
The map takes strings, numbers and bools; each is carried as the text of the
literal, so tenant = 42 reaches the file as "42". A name the file does not
declare is ignored. vars = null and vars = {} are both read as “no values
given”, and a value that is not a map — vars = "acme", vars = [1, 2] — is
refused with atlas.hcl "vars" at atlas.hcl:3 must be a map of values.
Two data sources may not both select the same file with different vars and
both be selected by one src: the parse refuses and names both blocks, rather
than picking one and making the desired state depend on map order. Two spellings
of one path — path = "s.hcl" in one block and path = "./s.hcl" in the other —
are the same file and are refused the same way. A file that the src does not
evaluate to is not part of that verdict, and neither is the branch a conditional
did not take — src = var.use_app ? data.hcl_schema.app.url : data.hcl_schema.other.url reads the vars of the branch it takes, even when both
blocks name the same file, and so does an index over that conditional.
A null reaching a name Ptah acts on is refused, and the refusal names the type
the setting wants. With variable "s" { type = string, default = null }, both
dev = null and dev = var.s produce
atlas.hcl "dev" at atlas.hcl:8 must be a string; the declared type of the
variable makes no difference, and the bool, number and list(string)
settings behave the same way. This is stricter than the community binary, which
reads a null as an unset field and exits 0. It is the same standing divergence
as the label-arity and duplicate refusals, and it can only reject a file that
binary reads, never accept one it rejects. Names Ptah merely reports as ignored
are the other case and do accept null — see
Settings Ptah does not act on are still type-checked.
Project data source evaluation
Section titled “Project data source evaluation”Project data sources are lazy. Ptah evaluates a recognized source only when a
selected environment, a global lint or diff block, a top-level attribute,
or a local value references it. Dependencies between locals and data sources
run first. Declaring an unreferenced source does not open a database, start a
program, read a runtime variable, or render a directory.
The lazy set also recognizes remote_schema, aws_rds_token, and
gcp_cloudsql_token, matching the community binary’s treatment of valid
unreferenced blocks. Referencing aws_rds_token or gcp_cloudsql_token still
fails explicitly because Ptah does not implement it; remote_schema resolves
through Ptah’s OCI backend (see Remote schema data
source). An unknown type, including
composite_schema, fails during structural validation even when unreferenced.
Remote schema data source
Section titled “Remote schema data source”data "remote_schema" names the OCI artifact holding a desired schema. name
is required; tag selects a moving tag and defaults to latest, and version
selects a write-once tag. Set PTAH_ATLAS_REGISTRY to the namespace holding
them.
data "remote_schema" "app" { name = "app" tag = "prod"}
env "local" { url = "postgres://localhost:5432/app?sslmode=disable" src = data.remote_schema.app.url}The block resolves to an internal marker and fetches nothing at parse time. A project file is read by every verb, so pulling an artifact the run never uses would make an unrelated command fail whenever the registry is unreachable.
The marker also keeps the capability off the flag surface: --to oci://… and
--url oci://… remain refused on the Atlas-compatible commands, because the
community binary answers that spelling with unknown driver "oci". Native Ptah
reads the artifact directly instead, with no project file:
ptah schema inspect --schema-file oci://ghcr.io/acme/app:prodRemote directory data source
Section titled “Remote directory data source”data "remote_dir" pulls a migration directory from an OCI registry.
data "remote_dir" "app" { name = "app"}
env "local" { migration { dir = data.remote_dir.app.url }}name is required and names the repository. tag 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, so a block carrying both
names two different artifacts.
Set PTAH_ATLAS_REGISTRY to the namespace the repository resolves against —
for example registry.example.com/acme, which makes the block above resolve to
oci://registry.example.com/acme/app:latest. Without it the reference is
refused rather than guessed. Set PTAH_ATLAS_REGISTRY_PLAIN_HTTP=1 to reach a
registry over plain HTTP, which is intended for a local registry in a test.
Both variables are Ptah extensions, so PTAH_ATLAS_STRICT_COMPAT refuses them.
The directory is read-only. Ptah pulls it and reads it; it does not write back
to a registry, so migrate new, migrate diff and migrate hash refuse
against it and name the reference they refused. Write to a local directory and
publish it with ptah migrations push.
migration.dir takes the same reference
Section titled “migration.dir takes the same reference”An env can name the registry directly, without declaring a data source:
env "prod" { url = getenv("DATABASE_URL")
migration { dir = "atlas://app?tag=prod" }}It resolves against PTAH_ATLAS_REGISTRY exactly as the data source does, and
produces the same read-only directory, so migrate status, migrate apply,
migrate validate and migrate lint work against it while the writing verbs
refuse and name the reference.
Nothing is fetched while the project file is read. A project file is read
by every command, so an env whose migration.dir names a registry does not
make schema inspect --env prod contact one — the pull happens when a command
opens the directory, and so does the refusal when PTAH_ATLAS_REGISTRY is
unset:
Error: atlas migrate status --dir: capture migrations directory: capture filesystem snapshot:atlas:// references require an OCI backing registry in Ptah: set PTAH_ATLAS_REGISTRY to thenamespace holding them, for example PTAH_ATLAS_REGISTRY=ghcr.io/acme, or write the oci://reference itselfSQL data source
Section titled “SQL data source”data "sql" executes one query against url. The query must return one
column. Optional positional args accept strings, booleans, numbers, and
nulls. The result object contains the row count, the first row, and all rows:
data "sql" "tenants" { url = "sqlite://app.db" query = "SELECT name FROM tenant WHERE active = ? ORDER BY name" args = [true]}
locals { first_tenant = data.sql.tenants.value all_tenants = data.sql.tenants.values tenant_count = data.sql.tenants.count}When no rows match, value is null and values is an empty tuple. Strings,
booleans, integers, floating-point values, byte strings, and timestamps are
preserved as HCL values. Every non-null row must have the same HCL type; a
heterogeneous result, a null result, or a query with more than one column fails
explicitly.
External data source
Section titled “External data source”data "external" runs an argv directly and returns its standard output as a
string without trimming it:
data "external" "release" { program = ["./release-version", "--format", "plain"] working_dir = "tools"}No shell runs, so metacharacters are literal arguments. A relative
working_dir resolves from the directory holding atlas.hcl; omitting it
inherits the process working directory. Caller cancellation applies, execution
is capped at 60 seconds, standard output is capped at 64 MiB, and errors expose
only a sanitized bounded standard-error tail.
Runtime variable data source
Section titled “Runtime variable data source”data "runtimevar" reads url through Go CDK and returns the exact bytes as a
string:
data "runtimevar" "password" { url = "file://./secrets/password?timeout=2s"}Supported URL openers are constant, file, http, https, AWS Parameter Store and
Secrets Manager, and Google Cloud Runtime Config and Secret Manager. The
optional timeout query parameter overrides the 10-second default and is
removed before the provider receives the URL.
Template directory data source
Section titled “Template directory data source”data "template_dir" parses its files as one shared Go text/template set and
exposes an immutable rendered migration-directory URL:
data "template_dir" "tenant" { path = "migrations.tmpl" vars = { table = "tenant" }}
env "local" { url = "sqlite://app.db" migration { dir = data.template_dir.tenant.url }}Missing template keys fail. Root migrations can invoke definitions from nested
shared files because every file below path joins the template set. Only
root-level names ending in lowercase .sql are executed and emitted as
migrations; nested files, uppercase .SQL files, and other extensions are not
emitted themselves. Each vars value must be a string, number, boolean, or a
homogeneous list of those types produced with tolist. Numbers reach Go
templates as float64 values.
Ptah computes atlas.sum for the rendered files and captures the result before
database work. migrate new and a writing migrate diff synchronize their new
root SQL files and atlas.sum back to path without rewriting existing
templates. A hash-only run does not create atlas.sum in the source directory.
path resolves from the directory holding atlas.hcl and is confined to it,
including through symbolic links. A relative path remains relative in the
result URL, so the example produces mem://migrations.tmpl/tenant; an absolute
path produces a mem:///absolute/path/tenant URL.
External schema data source
Section titled “External schema data source”data "external_schema" declares a program whose standard output is the
desired schema. Selecting its .url as an env’s desired-state source makes
that program the source of truth for the environment:
data "external_schema" "app" { program = ["python3", "export.py"] format = "sql"}
env "dev" { url = "sqlite://app.db" src = data.external_schema.app.url}The program runs directly with an explicit argument vector — never through a
shell — and must print the complete desired schema to standard output. This is
the atlas.hcl spelling of the native ptah.yaml external_schema block and
the --schema-cmd flag; all three share one execution path. See
External and ORM schema sources.
| Attribute | Meaning |
|---|---|
program |
Required argv list. program[0] is the executable; no shell runs. |
format |
Stdout format: sql (default), hcl, or yaml. Ptah extension. |
working_dir |
Program working directory. Relative values resolve against the atlas.hcl directory. Ptah extension. |
env |
Extra KEY=VALUE entries for the program. PATH and PWD cannot be overridden. Ptah extension. |
The data source follows strict placement rules:
- Its
.urlvalue is only valid as the selected env’s desired-state source (env.srcorenv.schema.src) and must be that source’s only value. Referencing it fromurl,dev,migration.dir, orexcludefails explicitly. - A declared-but-unreferenced data source is ignored and never executed.
- When the selected env’s desired state is an external schema source, it
replaces a
ptah.yamlexternal_schemablock wholesale, so the two config files never mix into one hybrid program configuration.
Executing repository-controlled code from an auto-discovered config file requires an explicit opt-in, in both binaries:
- Native
ptahcommands that consume project config (for exampleptah schema render --env dev) require--allow-external-schemaor itsPTAH_ALLOW_EXTERNAL_SCHEMAenvironment twin. Without it, the command fails withatlas.hcl data.external_schema is disabled by default; pass --allow-external-schema to execute it. ptah-compatkeeps the Atlas-identical flag surface, so the opt-in is thePTAH_ALLOW_EXTERNAL_SCHEMA=1environment variable. Without it, commands fail during source classification, before the program could run.
ptah-compat schema diff, schema apply, schema inspect (spelled
--url env://src), and migrate diff consume the source. schema plan and
schema test do not support it yet and fail explicitly.
Community Atlas rejects this data source entirely: measured 2026-08-01 with a
logged-out Atlas CE v1.2.0 binary, an atlas.hcl declaring
data "external_schema" fails with exit 1 and
Error: data.external_schema is not supported by the community version of Atlas. Ptah evaluates it in the open build, behind the opt-in described
above.
When an atlas.hcl migration block is present, Ptah defaults
revision-format to atlas, so migration commands use
atlas_schema_revisions unless an explicit CLI flag overrides it.
migration.dir values declared in atlas.hcl resolve relative to the directory
containing that atlas.hcl file and must remain inside that project root after
symbolic-link resolution. The same confinement applies when the project file
uses an absolute value.
Explicit CLI --dir values keep CLI semantics and resolve relative to the
process working directory unless they are absolute. Apply, down, status, lint,
set, and native repair commands open the resolved directory through a rooted
handle and capture an immutable snapshot before database work. Relative CLI
traversal and symlink escapes are rejected; explicit absolute paths remain
supported.
Parent-relative paths that resolve outside the project root, absolute paths
outside it, and symbolic links that leave it fail as outside allowed root.
Non-local URI schemes in migration.dir and schema.src fail explicitly when
a command needs that configured value; an explicit CLI path flag still wins
before URI validation.
Named output templates
Section titled “Named output templates”An exporter block declares a Go template, and an env’s exporter attribute
picks which one --export renders through:
exporter "markdown" { template = "## Rollout\n{{ range .Changes }}- {{ .Cmd }}\n{{ end }}"}
env "local" { url = "sqlite://app.db" dev = "sqlite://dev.db" exporter = "markdown"}ptah-compat schema diff --env local --from ... --to ... --exportptah-compat schema inspect --env local --exportAn exporter is --format with the template kept in the project instead of in
every invocation, over the same report that flag renders. That is the whole
design: the two verbs already render through templates, so a named format needs
no evaluator of its own, and a declarative description of output structure would
have been a second language to learn, document and version for the same result.
The block is top-level rather than env-local because an output format is not a property of a database — every env can select the same one by name.
Every way an export can fail to name a template is refused rather than answered
with the ordinary report: no project config, an env selecting none, an env
naming one nothing declares, a block declaring no template, a name declared
twice, and --export passed alongside --format. Each of those would otherwise
print the default output and let you believe your exporter ran.
Environment selection
Section titled “Environment selection”Use Atlas project flags on commands under ptah-compat schema ... and
ptah-compat migrate ...:
ptah-compat schema inspect --config project.hcl --env localptah-compat migrate apply -c project.hcl --env localptah-compat migrate hash --env local --var dir=migrations--config and -c select a local project config path. file:// config URLs
are accepted; other URL schemes fail explicitly. --var name=value can be
repeated. Repeating the same variable name produces a string list for supported
Atlas HCL expressions.
Naming a project config from the native binary
Section titled “Naming a project config from the native binary”The native ptah binary reads the same file. Discovery finds ./atlas.hcl,
and --config names one anywhere else:
ptah migrations status --config conf/staging.hcl --env staging--config takes a ptah.yaml as it always has; a path ending in .hcl is read
as an Atlas project config instead. The extension decides, not the contents, so
a path resolves the same way every time rather than changing meaning while the
file is being edited.
Naming one replaces discovery rather than adding to it: ./atlas.hcl is not
also read, so an env defined in both files resolves from the one named. A
./ptah.yaml is still read underneath for what the Atlas file leaves unset,
which is the precedence discovery already had.
Before this, an Atlas project config that was not ./atlas.hcl could be reached
from ptah-compat and not from ptah — backwards for a project moving from the
compatibility surface to the native one.
A --var carrying no = is refused where it is written, on every verb, in the
community CLI’s own words
(#1231):
ptah-compat migrate status --dir file://migrations --url "$DATABASE_URL" --var novalue# Error: invalid argument "novalue" for "--var" flag: variables must be format as key=value, got: "novalue"The refusal precedes everything the verb itself requires — the missing --url,
the missing --dir, the arity check — and it fires on every verb, including the
ones that never read the flag. A value is checked field by field as CSV, so
--var a=1,b is refused naming b. Only the separator is required within a
field: an empty name and an empty value are both accepted, because both are
accepted there.
PTAH_VAR carries the same rule and the same sentence, since the value reaches
the same check:
Error: invalid argument "novalue" for "--var" flag: variables must be format as key=value, got: "novalue"It also carries the same scope. schema test forwards to a native runner, and
a data.hcl_schema block that declares no vars refuses the run’s values
whether they were spelled --var or PTAH_VAR — the variable is read once, by
this surface, and the native command receives only what the scope decided. A
scope closed against the flag but open to the environment would be no scope at
all, and the leak would be silent: the run still passes, against a schema
nobody asked for.
Variable blocks accept the type constraints string, number, bool,
list(string), and map(string). --var overrides convert to scalar types,
and repeated flags fill a list(string) variable. map(string) values come
from defaults or HCL expressions because the string/list flag syntax does not
encode maps. Overrides of the wrong shape, defaults that do not match the
declared type, and other constraints such as object(...) fail with named errors.
sensitive = true is accepted; parse-time conversion errors print
(sensitive value) instead of the variable’s value, though a sensitive value
interpolated into a URL or path can still appear in downstream errors that
print that URL or path. validation blocks remain unsupported and fail
explicitly.
If an atlas.hcl file has exactly one env block, named or unnamed, Ptah can
use it as the default. Atlas-compatible migrate apply does not need to select
an environment when both --url and --dir are explicit. Other ambiguous or
unsupported environment layouts fail instead of guessing.
Expand one environment into several targets
Section titled “Expand one environment into several targets”Use for_each when one Atlas environment applies the same migration directory
to several databases:
env { for_each = toset([ "sqlite://bar.db?_fk=1", "sqlite://foo.db?_fk=1", ]) name = atlas.env url = each.value
migration { dir = "file://migrations" }}atlas.env is the requested --env value. each.key and each.value expose
the current collection entry. For an unlabeled block,
name must depend on atlas.env; a static name or a name based only on
each.key does not define the requested environment. A labeled block uses its
label as the initial candidate when name is absent. Every expanded instance
of an admitted block is evaluated before its resulting name is filtered, so an
invalid nonmatching instance still fails. Tuples and lists keep source order;
objects, maps, and sets use stable key order. As a Ptah extension, typed list
and map values are accepted for env for_each in addition to tuple, object,
and set values.
ptah-compat migrate apply --env local runs every selected target sequentially
and stops at the first failure. A formatted run emits one document per attempted
target with one newline between adjacent documents. Commands that require one
project instance reject a multi-instance selection instead of choosing one.
Structural validation and ignored names
Section titled “Structural validation and ignored names”Ptah gives each project-config name one of three outcomes:
| Outcome | Result |
|---|---|
| Supported | Parsed into project config; expressions are evaluated for the selected environment. |
| Structurally unsupported | Fails with unsupported atlas.hcl construct ..., including in an unselected environment. |
| Ignored by Atlas CE | Accepted for compatibility and reported on stderr as having no effect. |
The ignored category contains only names that Atlas CE itself accepts without acting on. Ptah does not silently discard them. A successful command reports each ignored source location once:
warning: atlas.hcl attribute "project" at atlas.hcl:2 is ignored for Atlas compatibility and has no effectStructured settings must be written as blocks
Section titled “Structured settings must be written as blocks”A setting Atlas CE decodes into a structure takes a block body. The same name written as an attribute with an object value is refused:
Error: atlas.hcl "lint" at atlas.hcl:3 must be a block, or an empty objectThis is not a Ptah restriction. Atlas CE routes both spellings to the same
field, and its object decoder refuses every member name it finds there —
including the members the block spelling accepts, so lint = { latest = 1 }
and lint = { anything = 1 } both fail on the community binary. The attribute
spelling carries no configuration on either binary. The two values that are
accepted are an empty object and null, for the same reason: they carry
nothing.
The affected names were measured one at a time and the set is neither “every block name” nor scope-independent:
| Scope | Must be a block | Tolerated as an attribute |
|---|---|---|
| top level | diff, lint, test |
atlas, data, format, locals, migration, schema, variable |
env |
diff, format, lint, migration, schema, test |
— |
diff and env.diff |
skip |
concurrent_index |
lint and env.lint |
git |
concurrent_index, condrop, data_depend, destructive, incompatible, nestedtx |
env.format |
migrate, schema |
— |
env.migration |
repo |
skip_report |
env.schema |
repo |
mode |
test and env.test |
migrate, schema |
everything else |
diff and lint are the two blocks that may sit at the top level as well as
inside env, and their nested names behave the same in both places: top-level
diff { skip = { k = "v" } } and lint { git = { k = "v" } } are refused, the
same as their env spellings.
The format, migration and schema rows stay env-only, and that is measured
rather than assumed. A top-level format, migration or schema block is not
decoded into those structures by the community binary, so top-level
format { schema = { k = "v" } }, migration { repo = { k = "v" } } and
schema { repo = { k = "v" } } all exit 0 — Ptah drops the whole block with an
ignored-block warning and exits 0 too.
The test row is the one that applies inside a block with no effect at all.
Neither binary implements test: it is dropped whole and reported as ignored.
The community binary still runs its object decoder on migrate and schema
within it, so test { schema = { q = "v" } } fails on both while
test { schema = {} } and test { schema "s" { src = ["file://t.hcl"] } } do
not.
Writing any of these as a block is unaffected.
This refusal is a value rule, not a structural one, so it follows the same
selection boundary as every other value: it applies to the environment the
command selects. An env "prod" carrying lint = { k = "v" } does not fail a
command run with --env dev, because Atlas CE does not decode an unselected
environment either. The value is read after var, local and data are
available, so lint = local.nothing resolves normally.
Settings Ptah does not act on are still type-checked
Section titled “Settings Ptah does not act on are still type-checked”A handful of names are decoded by Atlas CE into a plain string, bool or string-list field that Ptah has no equivalent for. Ptah accepts the name and reports it as having no effect, but the value still has to be the kind the community binary requires, because that binary refuses a wrong-typed value before any command runs:
Error: atlas.hcl "drop_column" at atlas.hcl:5 must be a bool| Scope | Name | Required value |
|---|---|---|
diff.skip and env.diff.skip |
add_schema, modify_schema, add_table, modify_table, add_column, modify_column, drop_column, add_index, modify_index, drop_index, add_foreign_key, modify_foreign_key, drop_foreign_key |
a bool |
lint and env.lint |
review |
a string |
env |
include |
a list of strings |
env.migration |
exclude |
a list of strings |
| top level | env written as an attribute |
a block; only null is accepted as a value |
null is accepted for every one of them, as it is on the community binary.
drop_schema and drop_table are absent from the table because Ptah acts on
them, so they are ordinary supported names rather than ignored ones.
This is a value rule too, with the same selection boundary and the same reading
order as the block rule above, so drop_column = var.flag resolves normally.
A list-valued name takes a tuple, a list or a set of strings, so
include = toset(["a", "b"]) is accepted. A null element inside one is not,
which is the one place null stops being accepted here — the community binary
answers null value is not allowed for ["public.t1", null], and it makes no
difference whether the list was written literally or came from a
variable "tables" { type = list(string) }. One bare string is not a
one-element list, and an object is refused even when it is empty —
include = {} fails where repo = {} succeeds. That empty object is the shape that separates a
list-valued name from a struct-valued one, and the top-level env row takes it
one step further: Atlas CE fills that field from env blocks and decodes no
value spelling for it at all, so env = {} is refused as well. The env block
spelling is untouched.
env.migration.baseline is no longer one of them. It is a supported name now:
migrate apply reads it as the config spelling of --baseline, and the flag
still wins when it is passed. See
migration.baseline. env.include and
env.migration.exclude are still type-checked and not acted on — neither has a
Ptah setting behind it, and env.exclude is the separate, supported name.
The scope matters throughout — a baseline written at env level, inside
lint, or in a top-level migration block is not decoded by the community
binary, and neither is an include outside env or an exclude outside
env.migration, so any value is accepted in those places.
The membership is measured name by name and is not “every change kind”:
add_view, drop_func, modify_trigger, add_type, drop_sequence,
add_check, drop_role, add_policy, add_extension and drop_domain are
among the names the community binary does not decode inside skip, so any value
is accepted for them.
An unknown block is tolerated where an unknown attribute is
Section titled “An unknown block is tolerated where an unknown attribute is”A body that tolerates a name it does not implement tolerates it in either
spelling. diff, env.diff, lint, env.lint, env.format.migrate and
env.format.schema accept an unknown attribute and an unknown nested block,
so a format extension such as format { migrate { custom { } } } is accepted
and reported as having no effect rather than refused, exactly as the community
binary reads it.
The bodies that refuse a nested block are the leaves, and they are the whole list:
| Scope | Leaf bodies that refuse a nested block |
|---|---|
diff and env.diff |
concurrent_index, skip |
lint and env.lint |
concurrent_index, condrop, data_depend, destructive, git, incompatible, nestedtx |
env.schema |
mode, repo |
env.migration |
repo |
Error: unsupported atlas.hcl construct "anything" at atlas.hcl:5All 21 scope-and-leaf pairs were measured one at a time and the community binary exits 0 on every one of them, so this is a known remaining divergence in the loud direction — the same standing policy as the label-arity and duplicate refusals. It never accepts a project file that binary rejects.
Structural validation covers every env block, including environments that
are not selected for the current command. A structurally unsupported construct
therefore fails even when it appears in another environment: a data-source
field, a label on a supported block that takes none, a duplicate supported
block, or a nested block inside one of the leaf bodies above. An unknown
attribute and an unknown nested block in a tolerant body are not in that
category and are accepted in a selected and an unselected environment alike.
Expressions inside env blocks, including ignored attributes and
block bodies, are evaluated only in the selected environment. An unselected
environment may therefore refer to variables, files, or environment values
unavailable in the current invocation. Global variable, locals, and data
blocks are evaluated separately to build the shared context before environment
selection.