# HCL schema reference

Atlas-compatible HCL schema subset and Ptah parity extensions.

Source: https://docs.ptah.run/edge/reference/hcl-schema/

Ptah can read HCL schema files as desired schema input. The parser builds the
same schema IR as Go annotations and YAML schema files, then uses Ptah's normal
rendering and planning paths.

Ptah's HCL schema syntax includes a supported subset of Atlas HCL schema files
plus Ptah extensions for Go annotation parity. Ptah is an independent
implementation and is not affiliated with or endorsed by Ariga or Atlas.

## Command

```bash
ptah schema render --schema-file schema.hcl --dialect postgres
```

`--schema-file` accepts `.hcl` files for HCL schema input. Project
configuration in `atlas.hcl` is a different file type; see
[Atlas project config](../../atlas/project-config/).

All schema-file sources use the path boundary documented under
[Schema file paths](../native-commands/#schema-file-paths).

`ptah schema fmt [path ...]` rewrites `.hcl` schema files into HashiCorp
HCL's canonical layout, walking directory arguments recursively and printing
only the files that changed. `--check` reports non-canonical files without
rewriting them and exits non-zero, for CI formatting gates.

## Minimal schema

```hcl
schema "main" {}

table "users" {
  schema = schema.main

  column "id" {
    type = int
  }

  column "email" {
    type = varchar(255)
    null = false
  }

  primary_key {
    columns = [column.id]
  }

  index "idx_users_email" {
    unique  = true
    columns = [column.email]
  }
}
```

### How many `schema` blocks a document may declare

A document may declare as many schemas as the run can reach. A run whose URL
names one schema — any SQLite URL, a PostgreSQL-family URL carrying
`search_path=<one name>`, a MySQL-family URL naming a database — reaches one,
and a document declaring more than one top-level `schema` block is refused
there rather than narrowed. The count is of blocks: repeating
`schema "main" {}` in two files of a schema directory is two.

This holds on the native commands, not only the compatibility binary:
`ptah schema inspect --schema-file two-schemas.hcl --dev-url sqlite://dv?mode=memory`
refuses, because narrowing a desired state to the scope and reporting success
is a wrong answer wherever it happens. Give the run a realm-scoped URL to
describe every schema the document declares. A desired state with no URL to be
scoped by — Go annotation roots, `ptah schema test` — is unaffected.

See
[the Atlas-compatible schema commands](../../atlas/schema-commands/#a-schema-limited-run-refuses-a-multi-schema-hcl-desired-state)
for the message and the flag it names.

## Supported object subset

| Object | Supported shape |
| --- | --- |
| `schema` | Labels and comments for table namespace references. |
| `table` | Columns, keys, indexes, constraints, checks, row security, and Ptah `checks`, `custom`, `platform`, `api_name`, `openapi_name`, `graphql_name`, and `proto_name` extensions. |
| `column` | Type, nullability, defaults, generated/identity metadata, comments, checks, and Ptah `enum`, `platform`, `api_name`, `openapi_name`, `graphql_name`, `proto_name`, `api_type`, and `api_expose` extensions. |
| `primary_key` | `columns`; PostgreSQL also supports `include`, and MySQL and MariaDB support `type = HASH`. |
| `index` | `columns`, `on { column = ... }`, `on { expr = ... }`, `desc`, `on { nulls_first = ... }` or `on { nulls_last = ... }`, `unique`, `type`, `where`, `comment`, PostgreSQL include/storage options, and Ptah `platform` blocks for provider-owned source properties, such as a ClickHouse data-skipping index's `type` and `granularity`. |
| `constraint` | Ptah block used when annotation metadata cannot fit the Atlas-native constraint blocks, and for `EXCLUDE` constraints. `using` is refused on a `UNIQUE`, `CHECK` or `FOREIGN KEY` constraint. |
| `unique` | `columns`; PostgreSQL also supports `include` and `nulls_distinct`. |
| `foreign_key` | One local `columns` entry and one table-qualified `ref_columns` entry, plus optional `on_delete`, `on_update`, `deferrable` and `initially`. |
| `check` | `expr`. |
| `enum` | `values`, plus the `schema` that owns the type. A PostgreSQL enum is created in that schema and a column declared against it is qualified with it. |
| `extension` | PostgreSQL installation `schema`, `if_not_exists`, `version`, and comments. |
| `role` | PostgreSQL role attributes, including `password`. |
| `permission` | PostgreSQL table, schema, sequence, function and procedure permissions. A routine is `for = function.<name>` or `procedure.<name>`, with its argument types in the Ptah attribute `args`, because PostgreSQL overloads a name by them. The Ptah `columns` attribute limits a privilege to columns of a table. |
| `revoke` | Ptah block: privileges a role must not hold, with `from`, `for`, `args`, `privileges`, `columns` and `comment`. The Atlas community CLI has no such block and ignores it. |
| `default_privilege` | Ptah block: a PostgreSQL default privilege, with `for_role`, `schema`, `object_type`, `to`, `privileges`, `grantable`, `revoked` (privileges the grantee must not hold by default) and `comment`. Without `schema` it is the global default, which applies in every schema. |
| `function` | PostgreSQL metadata and raw body, with Atlas-style `arg` blocks or a Ptah raw `params` string. |
| `procedure` | The same shape as `function` without `return`, which a procedure does not have. Ptah block: the Atlas community CLI has no procedure block and ignores this one. |
| `view` / `materialized` | SQL body plus schema and comments. A `materialized` block accepts a ClickHouse `refresh` clause. |
| `trigger` | Trigger timing and every event its timing block sets (`insert`, `update`, `delete`, `truncate`), target, execution mode, function body, and comments. |
| `policy` | PostgreSQL RLS policy fields. |
| `row_policy` | ClickHouse `on`, `using`, `to`, and `as`. See [row policies](../../databases/clickhouse/#row-policies). |
| `sequence` | PostgreSQL `type`, `start`, `increment`, `min_value`, `max_value`, `cache`, `cycle`, `owned_by`, and `if_not_exists`. |
| `domain` | PostgreSQL `type`, `null`, `default`, and `check`. |
| `composite` | PostgreSQL composite type with ordered `field` sub-blocks. |
| `range` | PostgreSQL `subtype`, `subtype_opclass`, `collation`, `canonical`, and `subtype_diff`. Writing one of the four optional attributes as `""` says the range has none; omitting it says nothing about it, so a range that already carries one keeps it. |
| `hypertable` | TimescaleDB partitioning of the table the label names: `column`, and optionally `chunk_interval`, `if_not_exists` and the `schema` holding the table. The table may sit in another file of the schema directory; without a schema, the label names the one table of that name. |
| `continuous_aggregate` | TimescaleDB continuous aggregate: `as` for the `SELECT` it materializes, and optionally `materialized_only`, a comment and the `schema` holding it. |
| `synonym` | SQL Server alias: `target`, plus the `schema` the alias lives in and a comment. |
| `extended_property` | SQL Server named value: `value`, and the object it is attached to as `schema`, `table` and `column`. |
| `coordination_node` | Ptah block: a YDB coordination node, with `schema` and the settings of the [`//ptah:schema:coordinationnode`](../go-annotations/#ptahschemacoordinationnode) annotation as strings. |
| `data` | Ptah managed-data declaration with a table reference, key columns, and a file path relative to the HCL file. |

For YDB, an `index` block also accepts the
[index partitioning attributes](../../databases/ydb/#index-partitioning).
Use quoted `ENABLED` or `DISABLED` for the splitting switches, numbers for
partition counts and size, and a quoted value such as `ANY_AZ:2` for
`read_replicas_settings`. These are Ptah extensions; Atlas has no YDB driver.
An omitted setting leaves the existing index's value unchanged.

Omitted YDB coordination nodes remain unmanaged unless the document explicitly
claims a complete namespace. Ptah exports that claim in a versioned
`// ptah:feature-coverage` header. Keep the header when editing the export;
incompatible model definitions and duplicate accounts are refused. See
[coordination nodes](../../databases/ydb/#coordination-nodes) for declaration
and limit semantics.

The header belongs to HCL sources. Renaming the document to `.sql` does not
convert it; SQL source loading refuses this metadata instead of discarding it.

## Default privileges

A `default_privilege` block takes no labels. `for_role` names the role that
creates objects, and `to` names the role that receives their default privileges.
Each accepts a role reference or a string. A dot inside a quoted role name
stays part of that name.

`privileges`, `grantable`, and `revoked` accept lists of strings or bare privilege
names. A scalar names one privilege. Variables, locals, and functions can supply
strings or lists of strings. Null values and non-string list elements are
refused. Every `grantable` entry must appear in `privileges`.

Blocks with the same grantor, schema, object type, and grantee combine their
privileges. A privilege with a grant option keeps that option when another
block grants it without one. Conflicting comments and declarations that both
grant and revoke a privilege are refused. An omitted `schema` means the global
default, even when the export names a default schema.

An export preserves an unread default ACL with a `ptah:notdescribed` header.
An unnamed limit covers the whole default privilege namespace. A named limit
uses a JSON tuple of grantor, schema, object type, and grantee. A tuple without
the grantee covers one ACL class; an empty grantor in that tuple means
CockroachDB's `FOR ALL ROLES`. An empty schema means global defaults.

```hcl
// ptah:notdescribed default_privilege "[\"owner\",\"app\",\"TABLES\",\"reader\"]"
```

Reading this source leaves that ACL unmanaged. The limit does not declare an
empty privilege list or authorize a revoke.

## API export metadata attributes

API export metadata is a Ptah HCL extension. All values are quoted strings:

| Attribute | Accepted on | Meaning |
| --- | --- | --- |
| `api_name` | `table`, `column` | Shared OpenAPI, GraphQL, and Protobuf name fallback. |
| `openapi_name` | `table`, `column` | Exact OpenAPI component key on a table or property key on a column. |
| `graphql_name` | `table`, `column` | GraphQL type-name stem on a table or exact field identifier on a column. |
| `proto_name` | `table`, `column` | Protobuf message-name stem on a table or exact lower-snake-case field name on a column. |
| `api_type` | `column` | Contract-only type override shared by all three targets. It must name a type Ptah maps or a declared enum. |
| `api_expose` | `column` | Contract exposure: `read`, `write`, `read-write`, or `none`. |

Names resolve from the target-specific attribute, then `api_name`, then the
database identity. Ptah singularizes and PascalCases GraphQL and Protobuf table
stems into final type/message names; it does not rewrite an invalid explicit
field identifier. Invalid target names and collisions fail before output.
These attributes affect OpenAPI, GraphQL, and Protobuf exports only; they do not
change DDL or migration planning.

Canonical HCL rendering includes every non-empty attribute above. Parsing the
rendered document and rendering it again must produce the same bytes, and OCI
schema artifacts use that canonical form. Native Ptah and the default
compatibility profile preserve the extension. Strict Atlas CE mode rejects a
schema carrying it rather than silently dropping it. See
[API schema export](../../schema/export/#names-in-the-contract) for
source-neutral examples and the target-specific behavior.

An `extended_property` block names its owner with plain strings rather than
block references, and the level it stops at is the scope it applies to. A block
with no `schema` is a database-scoped property; with a `schema` and no `table` it
applies to the schema; `table` adds the table and `column` the column. SQL Server
addresses each level by name — `@level1name = N'users'` — so a `table` without a
`schema`, or a `column` without a `table`, names nothing and is refused when the
document is read.

```hcl
synonym "current_orders" {
  schema = schema.app
  target = "sales.orders"
}

extended_property "MS_Description" {
  schema = schema.app
  table  = "orders"
  column = "total"
  value  = "order total including tax"
}
```

A `materialized` block accepts a ClickHouse `refresh` string:

```hcl
materialized "event_stats" {
  as      = "SELECT count() AS c FROM events"
  refresh = "EVERY 1 DAY OFFSET 2 HOUR RANDOMIZE FOR 30 MINUTE APPEND"
}
```

The clause supports `EVERY`, `AFTER`, `OFFSET`, `RANDOMIZE FOR`, `DEPENDS ON`,
and `APPEND`. Omitting `refresh` or writing `refresh = ""` declares no schedule.
Removing a schedule replaces the view and discards its stored rows. HCL export
preserves supported schedules and refuses schedules the catalog cannot capture.
The schedule applies only to ClickHouse; see its [refresh rules](../../databases/clickhouse/#views-and-materialized-views).

The shared `refresh_strategy` attribute is refused on presence, before its
expression is read. For PostgreSQL materialized views, run refreshes from your
own scheduler.

Every `schema "pg_catalog" {}` or `schema "information_schema" {}` block is an
explicit schema declaration, even when an extension also refers to it, and is
refused before SQL, including when comparison would otherwise report no changes.
To preserve an extension already installed in a
server-owned namespace without requesting `CREATE SCHEMA`, write the placement
as a string, for example `schema = "pg_catalog"`, and omit the schema block.
Ptah's generated HCL uses that spelling. CockroachDB likewise refuses its exact
`crdb_internal` namespace. Quoted lookalikes such as `schema "PG_CATALOG" {}`
or `schema "CRDB_INTERNAL" {}` remain user schemas.

Spanner refuses the schema names its server refuses to create: `spanner_sys`,
`spanner`, `default`, every name that starts with `spanner_` in lower case, and
any spelling in another case of `public`, `information_schema`, `pg_catalog`,
`spanner_sys`, `spanner` or `default`, such as `schema "Public" {}`. It accepts
`schema "public" {}`, and `schema "SPANNER_APP" {}` and `schema "PG_TOAST" {}`,
which the server creates.

The `extension.schema` attribute accepts an HCL string template or an exact
one-name schema traversal such as `schema.extensions` or
`schema["Extension Store"]`. A string template may evaluate declared variables;
a direct `var.*` traversal, another object namespace such as `table.*`, and an
over-qualified traversal such as `schema.extensions.extra` are refused instead
of being reinterpreted as schema names.

The two-label form `extension "extensions" "citext" {}` uses its first label
as the installation schema. If the block also carries a `schema` attribute,
that value must resolve exactly to the first label. An explicit empty value is
still present, so `schema = ""` conflicts with a nonempty schema label; on the
one-label form it explicitly selects the target's default schema.

Unsupported semantics fail explicitly. Ptah does not silently drop HCL objects
that it cannot represent in the schema IR. The Ptah `ops` index attribute
preserves a Go annotation operator class.

## Variables, locals, and expressions

A schema file may declare `variable` and `locals` blocks and read them back
through the `var.` and `local.` namespaces:

```hcl
variable "status" {
  type    = string
  default = "active"
}

locals {
  state_column = "state_${var.status}"
}

schema "app" {}

table "t" {
  schema = schema.app
  column "state" {
    type    = text
    default = var.status
    comment = local.state_column
  }
}
```

A `variable` block requires `type`, which is one of `bool`, `int`, `number`,
`string`, or `list`, `map` or `set` of those. It accepts `default` and
`description`. A variable with no `default` needs a value from `--var`:

```bash
ptah-compat schema diff --dev-url "sqlite://file?mode=memory" \
  --from file://empty.hcl --to file://schema.hcl --var status=live
```

One `--var` occurrence carries comma-separated `name=value` assignments, and the
flag may be repeated. A variable that ends with no value fails with
`missing value for required variable "status"`. `--var` does not require an
`atlas.hcl`; when one is present it also supplies that file's variables.
`ptah schema test --schema-file schema.hcl --var status=live` applies the same
override before it provisions the throwaway database.

Attribute values are evaluated. A function call resolves against the function
set, a `var.` or `local.` reference resolves against the blocks above, and
anything that will not resolve is an error rather than the expression's own
source text. References that name schema objects — `schema.app`, `column.state`,
`enum.status` — and type expressions such as `text` or `varchar(255)` are read
as written and are not evaluated, so a `var.` reference in a `type` is rejected.

## Go annotation parity

Every schema semantic accepted by Ptah's Go annotation parser has an HCL
representation. Export may use Ptah-specific blocks and attributes when the
Atlas-compatible shape would lose information:

```hcl
schema "app" {}

enum "enum_user_status" {
  values = ["active", "disabled"]
}

table "users" {
  schema = schema.app
  checks = ["id > 0"]
  custom = "WITHOUT OIDS"

  column "status" {
    type = enum_user_status
    enum = ["active", "disabled"]
  }

  platform "mysql" {
    override "engine" {
      value = "InnoDB"
    }
  }

  constraint "users_no_overlap" {
    type      = "EXCLUDE"
    using     = "gist"
    elements  = "id WITH ="
    condition = "id > 0"
  }
}

function "lookup_user" {
  params = "IN user_id BIGINT, OUT display_value DOUBLE PRECISION"
  return = "DOUBLE PRECISION"
  lang   = SQL
  as     = "SELECT user_id::double precision"
}

data {
  table = table.users
  keys  = ["id"]
  file  = "users.yaml"
}
```

Embedded Go annotations export as finalized concrete columns and foreign keys.
Go struct and field names are provenance rather than schema semantics and are
intentionally not written to HCL. Role passwords are written as string
literals, and Ptah forces generated files containing them to owner-only `0600`
permissions. Treat those files as sensitive.

## PostgreSQL include columns

```hcl
table "users" {
  column "id" {
    type = int
  }

  column "covering" {
    type = int
  }

  primary_key {
    columns = [column.id]
    include = [column.covering]
  }

  index "idx_users_id" {
    columns = [column.id]
    include = [column.covering]
  }
}
```

Ptah preserves supported include columns through HCL parsing, SQL rendering,
SQL parsing, schema diffing, and database introspection paths where the dialect
supports the feature.

## Procedures

A procedure is written as its own block, not as a `function` with no `return`:

```hcl
procedure "reap_accounts" {
  schema = schema.app
  lang   = "plpgsql"
  arg "before" {
    type = timestamptz
  }
  as = "BEGIN DELETE FROM accounts WHERE seen_at < before; END;"
}
```

The distinction is not presentational. A routine's kind decides the verb that
creates and drops it, and Ptah compares procedures against procedures and
functions against functions. A procedure described as a function is therefore a
different object from the one in the database: applying such a description drops
the procedure and creates a function in its place.

Declaring `return` inside a `procedure` block is an error. Every other attribute
is the one `function` takes.

The block is not dialect-specific: every catalog reader that distinguishes a
procedure describes one with it. Round-tripping a database's own description
back to it is a no-op, measured on PostgreSQL 17 and MySQL 9.7.2.

On the Atlas-compatible surface the block is emitted as well. The community CLI
has no procedure block, ignores the one it finds, and leaving it out would
withhold description without making any document more readable to it.

## Function bodies

Function bodies are stored as raw SQL text. Ptah does not parse the dialect
sub-language inside each function body. PostgreSQL,
MySQL, SQL Server, and other dialects have different procedural languages and
require dialect-specific parsers. Go-annotation export writes function, view,
materialized-view, and trigger bodies as opaque HCL strings and reports a
warning for each body. Because cleanup is destructive, any such warning prevents
`--cleanup-go-annotations` from publishing HCL or removing annotations.

Table features in HCL export stay attached by database schema and table name.
Changing the Go struct name does not change their placement.

Cleanup also refuses any recognized standalone Go directive that is attached at
an unsupported scope or did not produce a parsed schema object. The diagnostic
names the source file and line, and the operation leaves both the output and Go
sources unchanged. Near-prefix comments are not directives and are preserved.

## Unsupported constructs

Unsupported HCL constructs return errors rather than partial output. Treat these
errors as compatibility gaps. Check [Conformance](../../atlas/conformance/)
and [Atlas docs coverage](../../atlas/docs-coverage/) before deciding whether the
gap is already tracked.
