# Configuration

Project config files, Atlas config subset, environment variables, and precedence.

Source: https://docs.ptah.run/v0.8.0/reference/configuration/

import { Tabs, TabItem } from '@astrojs/starlight/components';

Configuration precedence is:

| Rank | Source |
| --- | --- |
| 1 | Explicit CLI flags |
| 2 | Environment variables |
| 3 | `atlas.hcl` selected environment |
| 4 | `ptah.yaml` selected environment |
| 5 | Built-in defaults |

A `PTAH_<FLAG>` value must parse as the corresponding flag type. Ptah rejects a
malformed value before argument validation, command hooks, or database work
begins. For example, `PTAH_DRY_RUN=notabool` fails with
`invalid boolean value "notabool" for PTAH_DRY_RUN` instead of running with
the default `false` value. An explicit CLI flag wins without reading its
environment twin, including when that environment value is malformed. An empty
value remains unset for every flag type except boolean, where it is rejected;
see [Boolean environment variables](#boolean-environment-variables).

## The flags with no environment variable

A few flags do **not** bind to a `PTAH_*` variable. They have to be typed, or
written into a command line somebody wrote:

| flag | on |
| --- | --- |
| `--auto-approve` | `ptah schema apply`, `ptah db drop-all`, `ptah mcp`, `ptah assist`, `ptah assist explain`, `ptah assist context` |
| `--allow-database-inspect` | `ptah mcp`, `ptah assist`, `ptah assist explain`, `ptah assist context` |
| `--db-url` | `ptah schema test`, `ptah migrations test` |

Every other flag in the native CLI binds to one.

The approval flags are the first two. `--db-url` on the two test verbs is the
third, for a neighboring reason: those verbs mutate the database they are given
and treat it as disposable. Bound to the environment, an operator with
`PTAH_DB_URL` exported for ordinary work runs the suite against that database by
omitting a flag. Requiring the flag costs nothing, because a throwaway target is
a decision rather than a default.

That is deliberate, and it is the reason every other flag can safely have one: a
container, a CI job, or a Kubernetes Job can be configured entirely through the
environment, and none of that configuration can turn a review step into an
unattended apply. A variable exported once in a shell profile would otherwise
approve every later run in that shell.

A Kubernetes Job passes it in `args:` rather than `env:`, which is the same
distinction: what the workload *is* versus what it is *configured with*.

## Boolean environment variables

Every boolean `PTAH_*` variable follows one rule, whether it is a flag's
environment twin or a feature toggle read directly:

| State | Result |
| --- | --- |
| Not set | The documented default |
| Set to a valid boolean | Parsed and honored |
| Set to anything else | The command fails before doing any work |

The accepted spellings are Go's `strconv.ParseBool` spellings:

```text
true:  1  t  T  true  True  TRUE
false: 0  f  F  false  False  FALSE
```

Nothing is trimmed before parsing. `" true"`, `"true "` and an exported empty
value are all rejected, because a quoting or expansion mistake in a YAML
manifest, a CI environment file or a systemd unit is exactly the configuration
error this rule exists to surface. The refusal names the variable and the raw
value:

```text
Error: invalid boolean value "maybe" for PTAH_POSTGRES_INSPECT_ALL_ROLES
```

The refusal comes before the command connects to a database, writes a migration
file, applies schema changes, touches revision state, or produces a
machine-readable result on standard output. It also comes before the command's
own early returns: an invalid value is rejected even on a run that would never
have consulted it, such as a `migrate lint` that already names `--latest`, or a
`schema apply` whose `--exclude` selectors all match.

A command only validates the variables it owns. A malformed PostgreSQL
inspection variable does not break an unrelated SQLite command or
`ptah version`.

Non-boolean `PTAH_*` variables are unchanged: an empty value still reads as
unset for them.

### Atlas CE strict compatibility

`PTAH_ATLAS_STRICT_COMPAT` is a `ptah-compat`-only boolean policy selector. It
defaults to `false`, which keeps the complete Pro-like and best-effort
compatibility surface. Set it to `true` only for Atlas Community Edition oracle
or conformance runs:

```bash
PTAH_ATLAS_STRICT_COMPAT=1 ptah-compat migrate validate --dir file://migrations
```

The process resolves the selector before command construction. In strict mode,
generic `PTAH_<FLAG>` environment twins are not installed because Atlas CE does
not expose them. A present extension variable, including one whose command flag
is absent from the strict tree, is rejected rather than ignored. False values
for known boolean extension toggles remain valid; malformed values retain the
normal boolean diagnostic.

The check is based on Ptah's actual flag bindings and feature-toggle registry,
not on the `PTAH_` prefix alone. An ordinary value that an `atlas.hcl` reads
through `getenv`, such as `PTAH_DATABASE_PASSWORD`, remains available in strict
mode. Naming project inputs with that prefix does not turn them into Ptah
features.

Strict mode also applies its object inventory to live schemas. `schema inspect`,
`schema apply`, `schema diff`, and `schema clean` refuse a Pro-only object
before rendering, comparison, or mutation instead of copying CE behavior that
could omit, miscompare, or destroy it. On `schema clean`, the CE-registered
`--dry-run` and `--format` flags remain gated. The diagnostic names
`ptah-compat` and tells the operator to unset the selector; Ptah never directs
the operator to install Atlas. Leave strict mode off for Ptah's complete
cleanup, inspection, diff, and apply capabilities.

Strict inspection omits PostgreSQL's server-installed `plpgsql` extension and
baseline `PUBLIC USAGE` grant from rendered output. Strict cleanup executes the
validated plan after confirmation. On PostgreSQL it locks the planned tables,
revalidates the live inventory, compares the rebuilt cleanup plan with the
confirmed plan, and refuses drift before the first drop. Full mode preserves
the complete reader output and its established unscoped writer path.

The cleanup check covers the writer's complete destruction inventory. That
includes PostgreSQL procedures, aggregates, foreign tables, collations,
default privileges, and dependent objects such as triggers that disappear
with a table without appearing as a separate cleanup plan line.

Strict schema workflows also refuse YAML sources and an authored `schema apply`
lint policy that the CE execution path cannot enforce. Commands that execute,
convert, or replay migration bodies refuse Atlas txtar, every Ptah directive,
and SQL templates; checksum-only reads preserve those bytes. All inputs remain
available in the default, complete compatibility profile.

Which variables strict mode gates is derived from Ptah's boolean-variable
registry, not from a list kept beside it. Each variable states at its single
declaration site whether strict mode gates it or retains it, so a variable
added without stating anything is refused rather than ignored, and there is no
second list to forget.

These opt-in correctness controls remain available because they do not add an
Atlas capability: `PTAH_ALLOW_EXTERNAL_TEST_COMMAND`,
`PTAH_ALLOW_NONINTERACTIVE_EDIT`,
`PTAH_ATLAS_ALLOW_UNMATCHED_EXCLUDE`, `PTAH_HCL_STRICT_REDECLARATIONS`,
`PTAH_SQLITE_ALLOW_UNREGISTERED_VIRTUAL_MODULE`,
`PTAH_SQLITE_ALLOW_VIRTUAL_TABLE_DROP` and `PTAH_STRICT_DIR_QUERY`.

`PTAH_ALLOW_EXTERNAL_TEST_COMMAND` authorizes an `external` step in a
`.test.hcl` case, which runs a program on the machine executing the suite.

Ptah refuses one by default and this variable restores it, so it adds no
capability the pinned community binary does not already have; gating it would
leave a conformance run unable to exercise the step at all.

The program is executed as an argument vector with no shell, and the run is
refused before any database is provisioned when the variable is absent.

`PTAH_ALLOW_NONINTERACTIVE_EDIT` permits a scripted editor in a non-interactive
process; it does not add an editor or migration capability.
`PTAH_SQLITE_ALLOW_VIRTUAL_TABLE_DROP` restores the `DROP TABLE` the pinned
community binary plans for a SQLite virtual table anyway, and
`PTAH_SQLITE_ALLOW_UNREGISTERED_VIRTUAL_MODULE` lifts a refusal that is Ptah's
own, since the binary has no notion of a module this build cannot classify and
plans the drops regardless. Gating either would make strict mode the one place
Ptah is stricter than the binary it matches. Every other declared
boolean is gated, and a malformed value for any of them fails the process
whatever its classification. Native `ptah` does not read
`PTAH_ATLAS_STRICT_COMPAT`.

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. Thus `atlas.hcl` wins over `ptah.yaml`,
while environment variables and explicit CLI flags still win.
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.

Use `ptah.yaml` for Ptah-owned configuration and the supported `atlas.hcl`
subset for Atlas-compatible project config. The supported Atlas subset includes
local `variable` defaults and Atlas-style `--var name=value` overrides,
`locals`, `getenv`, `file`, `fileset`, `data.hcl_schema`, `data.sql`,
`data.external`, `data.runtimevar`, and `data.template_dir`. Recognized project
data sources are evaluated only when the selected config references them.
Supported Atlas env blocks can also set `schema.src`, `schema.mode`, `format`,
and local `diff` policy defaults for the `ptah-compat` binary's commands.
`ptah-compat migrate apply` expands env `for_each` collections and applies each
selected database target sequentially.

Ptah reads each selected project config once per command and converts it to a
typed configuration value. Migration database settings and online-DDL policy
therefore cannot come from different generations of a concurrently replaced
`ptah.yaml`. When an Atlas-compatible adapter delegates to a native command
that also consumes project configuration, the adapter passes the merged typed
snapshot instead of letting that command reopen either project file. This
currently applies to `migrate down`; other adapters evaluate Atlas project
configuration once and map supported values to explicit native command
arguments. An explicit `--config` path must exist; the conventional
`./ptah.yaml` is optional.

`--config` takes a `ptah.yaml` file. Pointing it at an `atlas.hcl` is refused by
name rather than reported as a YAML parse failure: the Atlas project config is
discovered as `./atlas.hcl` and selected with `--env`.

Every native command that accepts `--env` also accepts `--var name=value`, which
supplies a value for an `atlas.hcl` `variable` block that declares no default.
The flag is repeatable, and repeating one name builds a `list(string)`. This is
the flag the evaluator names when a variable cannot be resolved:

```text
$ ptah schema compare --env local --schema-file schema.sql
error: atlas.hcl variable "dburl" requires a default or --var dburl=value
$ ptah schema compare --env local --schema-file schema.sql --var dburl=sqlite://app.db
```

`env://` references resolve on the `ptah-compat` `--to` and `--from` flags. The
native `--schema-file` does not resolve them and says so, naming `env://` and,
for an attribute outside `src`, `schema.src`, `url`, `dev`, `migration`, and
`migration.dir`, naming the attribute.

For Atlas-compatible commands, plain local schema paths, relative `file://`
schema URLs, and relative `migration.dir` values declared in `atlas.hcl` resolve
relative to the directory containing that `atlas.hcl` file. Explicit CLI path
flags such as `--to`, `--from`, and `--dir` keep CLI semantics and resolve
relative to the process working directory unless they are absolute.

A project-configured `migration.dir`, whether relative or absolute, must remain
inside the directory containing `atlas.hcl` after symbolic-link resolution.
Parent traversal that resolves outside, an absolute outside path, and a
symbolic-link escape fail as `outside allowed root`. An explicit CLI `--dir` is
operator-owned and retains the CLI path behavior above.

## Two files, one project model

`ptah.yaml` and the supported `atlas.hcl` subset are both native Ptah project
languages. Neither is an import format for the other: each is parsed into the
same project-config model, and every native `ptah` verb that reads project
configuration reads either file. A project written in HCL needs no conversion
to YAML to be operated by native Ptah, and one written in YAML needs no HCL.

What differs is reach, not standing. The Atlas subset is bounded by what the
compatibility profile covers, so a setting only Ptah has is expressed in
`ptah.yaml`; Ptah's own HCL extensions live in the schema language rather than
the project file, and are listed in [HCL schema](../hcl-schema/). A project
using one of those is no longer a project Atlas reads.

Both files can be present at once, and are merged by the precedence above.
[Adopting an Atlas project](../../atlas/adoption/) walks the path that uses
this.

## A minimal project config

A native command selects an environment with `--env`, and reads it from either
project file: `./ptah.yaml`, or the supported subset of `./atlas.hcl`. The two
below are equivalent, and `ptah migrations status --env dev` answers the same
way beside each.

<Tabs syncKey="project-config">
<TabItem label="ptah.yaml">

```yaml
env:
  dev:
    url: sqlite:////tmp/ptah-dev.db
    migration:
      dir: ./migrations
```

</TabItem>
<TabItem label="atlas.hcl">

```hcl
env "dev" {
  url = "sqlite:////tmp/ptah-dev.db"

  migration {
    dir = "file://migrations"
  }
}
```

</TabItem>
</Tabs>

Run with the named environment:

```bash
ptah migrations status --env dev
ptah migrations up --env dev --verify-sum
```

The two files are not interchangeable everywhere: `--config` names a
`ptah.yaml` and refuses an `atlas.hcl` by name, because the Atlas project
config is discovered as `./atlas.hcl` and selected with `--env`. Where both
exist, `atlas.hcl` wins — see the precedence table above.

If a command needs project values from a config file with multiple
environments, pass `--env`. Commands whose required inputs are all explicit do
not need to select an environment. Ptah fails instead of guessing only when
project values are required and the environment remains ambiguous.

## Operational settings

Project config can also define timeouts, revision table layout, migration
directory format, transaction mode, backup destinations, pre-flight hooks,
webhooks, lint defaults, and online-DDL policy.

The `dev` value supplies the disposable database for migration generation and
the shadow rehearsal that `migrations down` performs before touching its target.

| Setting area | Example keys |
| --- | --- |
| Database target | `url`, `src`, `schema.src`, `dev`, `schemas` |
| External desired schema | `external_schema.program`, `external_schema.format`, `external_schema.working_dir`, `external_schema.env` |
| Migration directory and revisions | `migration.dir`, `migration.format`, `migration.revisions_table`, `migration.revision_format` |
| Safety and operations | `migration.pre_up_hook`, `migration.pg_dump_to`, `migration.webhook`, `migration.exec_order`, `migration.tx_mode` |
| Lint defaults and policy | `lint.dialect`, `lint.disabled-rules`, `lint.latest`, `lint.git.base`, `lint.destructive.error`, `lint.concurrent_index.error`, `lint.naming` |
| Online DDL | `online_ddl.tool`, `online_ddl.threshold_rows`, `online_ddl.args`, `online_ddl.fallback` |
| Diff policy (native `migrations generate`) | `diff.skip: [drop_table, drop_column, drop_index, drop_enum]`, `diff.concurrent_index`, `diff.concurrent_index_drop`, `diff.online_alter` |
| Atlas-compatible output | `format.schema.inspect`, `format.schema.apply`, `format.schema.clean`, `format.schema.diff`, `format.migrate.apply`, `format.migrate.diff`, `format.migrate.lint`, `format.migrate.status` |
| Atlas-compatible diff policy | `diff.skip.drop_table`, `diff.concurrent_index.create`, `diff.concurrent_index.drop` |

The native `diff` block shapes what `ptah migrations generate` emits: `diff.skip`
lists destructive change kinds (`drop_table`, `drop_column`, `drop_index`,
`drop_enum`) to omit — a `-- SKIP: ...` comment is written in their place — and
`diff.concurrent_index: true` requests `CREATE INDEX CONCURRENTLY` for new
indexes (PostgreSQL, capability-gated), while
`diff.concurrent_index_drop: true` requests `DROP INDEX CONCURRENTLY` for
standalone index removals under the same gate. A skipped change is never
emitted, so it never trips the `--check-destructive` gate.

`diff.online_alter: true` asks the server to apply the plan without blocking
the writes already running against its tables. Each engine family is asked in
its own grammar, and a target with neither is planned as if the setting were
off.

- **MySQL and MariaDB**: every `ALTER TABLE` carries `ALGORITHM=INPLACE,
  LOCK=NONE`. The server refuses the statement when it cannot honor it, so a
  change that would copy the table fails at the statement rather than
  proceeding under a copy nobody asked about, and the refusal is reported as
  the server declining to do it online.
- **PostgreSQL**: there is no such clause, so the safe form is generated. A
  `CHECK` or `FOREIGN KEY` constraint is added `NOT VALID` and completed by its
  own `VALIDATE CONSTRAINT`, whose scan takes `SHARE UPDATE EXCLUSIVE` rather
  than `ACCESS EXCLUSIVE`.

The PostgreSQL pair buys that weaker lock only when the two statements commit
separately, so a generated migration carrying one runs outside a transaction. A
plan that mixes it with ordinary statements is refused: the constraint change
belongs in a migration of its own. `ptah schema apply` executes the statements
itself, so it refuses the setting unless it runs with `--tx-mode none`, rather
than holding the addition's lock through the validation scan.

On MySQL and MariaDB a table rename is refused under this setting, and that is
the server's answer rather than Ptah's: measured on MySQL 8.4.6 and MariaDB
12.3.3, `ALTER TABLE ... RENAME TO ..., ALGORITHM=INPLACE, LOCK=NONE` returns
`1845` asking for `LOCK=EXCLUSIVE`. A column rename and a table comment are
accepted. A selected environment's
`diff.skip` replaces the top-level list; an explicit empty list clears all
inherited skip kinds.

`drop_table` also reaches the comparison, not only the rendered SQL. Ptah's
SQLite virtual-table guard refuses a comparison for the `DROP TABLE` it predicts,
and a project that skips table drops has already deleted that statement, so the
refusal does not fire — see [SQLite](../../databases/sqlite/). It is the only
skip kind that changes what a comparison refuses, and it changes nothing about
what a comparison computes.

The Atlas-compatible command tree lives in the separate `ptah-compat` binary,
the drop-in replacement for scripts that expect Atlas-style root commands.
Atlas project flags such as `--config`, `-c`, `--env`, and repeated
`--var name=value` belong to that tree only; it is not a separate
configuration surface.

## External desired schema

Use `external_schema` when an ORM, framework, or generator owns the desired
schema:

```yaml
external_schema:
  program: [".venv/bin/atlas-provider-sqlalchemy", "--path", "./models", "--dialect", "postgresql"]
  format: sql
  working_dir: ./app
  env: ["APP_ENV=dev"]
```

`program` is an explicit argument list and is executed without a shell.
`format` accepts `sql` (the default), `hcl`, or `yaml`. The block supplies the
desired schema to native `schema render`, `schema compare`, `schema drift`,
`migrations plan`, and `migrations generate` commands when `--schema-cmd` is
not set. An explicit command and its `--schema-format` take precedence;
`--schema-cmd=` disables the configured source. `--env` selects an
environment-scoped block. Empty command output is rejected. `PATH` and `PWD`
cannot be overridden through `external_schema.env`; use an explicit executable
path and `working_dir`.

Auto-discovered configuration never executes a program implicitly. Pass
`--allow-external-schema` to use the configured block. An explicit
`--schema-cmd` is already an opt-in and does not require that additional flag.
Relative `working_dir` values are constrained to the current working directory
after symlink resolution. Ptah bounds output, redacts secrets and terminal
control characters from stderr and parser diagnostics, and cleans up descendant
processes.

Continue with [Atlas project config](../../atlas/project-config/) for the supported
`atlas.hcl` subset.

:::note
Ptah config parsing is intentionally explicit. Unknown `ptah.yaml` keys and structurally unsupported `atlas.hcl` constructs fail with their source location. Names that Atlas CE accepts without acting on are the exception: Ptah accepts them for compatibility and warns that they have no effect. A rejected `ptah.yaml` key is reported by name, with its line and the keys that section accepts — never by the Go type the decoder was filling.

A name earns that exception only by measurement, never by not having been implemented yet. The test is to give the attribute a value its field cannot hold and run the community binary: a binary that refuses on the field's TYPE has decoded the field, and a name it truly ignores cannot produce that refusal. `env { schemas }` failed that test — it was warned about as having no effect while the community binary was acting on it — and it is now parsed and honored. See [Atlas project config](../../atlas/project-config/#envschemas).

The exception is also spelling-sensitive. A name the community binary decodes into a structured setting — `lint`, `diff`, `migration`, `schema`, `format`, `test` and their nested settings — takes a **block** body. Written as an **attribute** with an object value it reaches the binary's object decoder, which refuses every member name, including the members the block spelling accepts. `lint { latest = 1 }` is a policy; `lint = { latest = 1 }` is an error on the community binary and is now an error in Ptah too. The only attribute values accepted are an empty object and `null`, because those carry no configuration. Which names are affected was measured one at a time and does not follow from the block names: `lint { git = { … } }` is refused while `lint { condrop = { … } }` is tolerated, and `format`, `migration` and `schema` are refused under `env` but tolerated at the top level.
:::
