Skip to content
PtahPtah

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.

Ptah accepts these local configuration blocks:

  • top-level variable
  • top-level locals
  • data "hcl_schema" for local schema file data
  • data "external_schema" for program-generated desired state
  • data "sql" for a one-column database query
  • data "external" for direct program output
  • data "runtimevar" for Go CDK runtime-variable URLs
  • data "template_dir" for rendered migration directories
  • env blocks, with either one label or no label
  • top-level and env-local lint
  • top-level exporter for named output templates
  • env-local schema, migration, format, and diff

Referenced Atlas Cloud and remote-directory sources, registry constructs, and unsupported data-source types fail explicitly.

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
}
}
}
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.

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.

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.

Terminal window
# 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.

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.

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.

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 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.

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:

Terminal window
ptah schema inspect --schema-file oci://ghcr.io/acme/app:prod

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.

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 the
namespace holding them, for example PTAH_ATLAS_REGISTRY=ghcr.io/acme, or write the oci://
reference itself

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.

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.

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.

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.

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 .url value is only valid as the selected env’s desired-state source (env.src or env.schema.src) and must be that source’s only value. Referencing it from url, dev, migration.dir, or exclude fails 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.yaml external_schema block 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 ptah commands that consume project config (for example ptah schema render --env dev) require --allow-external-schema or its PTAH_ALLOW_EXTERNAL_SCHEMA environment twin. Without it, the command fails with atlas.hcl data.external_schema is disabled by default; pass --allow-external-schema to execute it.
  • ptah-compat keeps the Atlas-identical flag surface, so the opt-in is the PTAH_ALLOW_EXTERNAL_SCHEMA=1 environment 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.

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"
}
Terminal window
ptah-compat schema diff --env local --from ... --to ... --export
ptah-compat schema inspect --env local --export

An 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.

Use Atlas project flags on commands under ptah-compat schema ... and ptah-compat migrate ...:

Terminal window
ptah-compat schema inspect --config project.hcl --env local
ptah-compat migrate apply -c project.hcl --env local
ptah-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:

Terminal window
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):

Terminal window
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.

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 effect

Structured 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 object

This 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:5

All 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.