# HCL schema reference

Atlas-compatible HCL schema subset and Ptah parity extensions.

Source: https://docs.ptah.run/v0.8.1/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`. |
| `index` | `columns`, `on { column = ... }`, `on { expr = ... }`, `desc`, `on { nulls_first = ... }` or `on { nulls_last = ... }`, `unique`, `type`, `where`, `comment`, ClickHouse `granularity`, and PostgreSQL include/storage options. |
| `constraint` | Ptah block used when annotation metadata cannot fit the Atlas-native constraint blocks, and for `EXCLUDE` constraints. |
| `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, and sequence permissions. |
| `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. |
| `trigger` | Trigger timing, target, execution mode, function body, and comments. |
| `policy` | PostgreSQL RLS policy fields. |
| `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. |
| `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`. |
| `data` | Ptah managed-data declaration with a table reference, key columns, and a file path relative to the HCL file. |

## 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 no `refresh_strategy`. Ptah does not refresh
materialized views: one is populated when it is created, a changed `as` body is
reconciled as a drop and a create that populates it again, and it goes stale
only when its source data changes, which schema reconciliation cannot observe.
Refresh from your own scheduler.

The attribute is refused on presence while the document is parsed -- before its
value expression is read, so a bare identifier is refused the same way a string
is -- and the refusal names the materialized view and the reason.

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.

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.

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.
