Skip to content
PtahPtah

HCL schema reference

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.

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

All schema-file sources use the path boundary documented under 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.

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

Section titled “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 for the message and the flag it names.

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

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.

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

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:

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

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:

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.

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.

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

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 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 HCL constructs return errors rather than partial output. Treat these errors as compatibility gaps. Check Conformance and Atlas docs coverage before deciding whether the gap is already tracked.