Go annotations
When annotated structs own the schema, use this Go-specific frontend. For a source-neutral starting point, see work with a schema source.
Use Go annotations when your Go application owns the schema and the database should follow annotated model types. Ptah reads comments, not runtime Go tags, so the model remains ordinary Go code.
When to use them
Section titled “When to use them”| Use Go annotations when | Use another source when |
|---|---|
| The application structs already describe the domain. | A database team owns SQL or HCL directly. |
| You want code review to cover schema changes next to model changes. | You need an HCL schema construct Ptah has not implemented yet. |
| You want generated migrations from desired/live differences. | You only need to apply an existing migration directory. |
Model the schema
Section titled “Model the schema”The smallest annotation source that is still useful in a real project is a table, a primary key, and a unique constraint:
models/ account.gomigrations/Create models/account.go:
package models
//ptah:schema:table name="accounts"type Account struct { //ptah:schema:field name="id" type="SERIAL" primary="true" ID int
//ptah:schema:field name="email" type="TEXT" unique="true" not_null="true" Email string}Ptah recursively reads regular *.go files under each --root-dir. It skips
*_test.go, hidden directories, and directories named exactly vendor.
Names that merely contain that word, such as myvendor, remain part of the
source tree.
Render the desired SQL before connecting to a database:
ptah schema render --root-dir ./models --dialect postgresExpected output includes:
CREATE TABLE "accounts" ( "id" SERIAL PRIMARY KEY NOT NULL, "email" TEXT UNIQUE NOT NULL);The exact type rendering depends on the selected dialect and field tags. To smoke-check without any daemon, render the SQLite dialect to a file:
ptah schema render --root-dir ./models --dialect sqlite >/tmp/ptah-schema.sqlsed -n '1,80p' /tmp/ptah-schema.sqlStandard output contains SQL only. Source-loading progress, schema counts, and the dependency summary go to standard error, so the redirected file can be executed unchanged. For PostgreSQL-family, MySQL-family, SQL Server, and Spanner targets, Ptah creates all tables before adding foreign keys. SQLite keeps foreign keys inline because it cannot add them after table creation.
Malformed foreign keys and constraints unsupported by the selected dialect fail before Ptah emits any SQL. The output never silently omits a declared foreign key. Ptah also checks referenced-key policy, compatible column types, constraint-name scope, and dialect-specific index or storage restrictions.
Always pass --dialect when redirecting executable SQL. Without it, Ptah
attempts the built-in review targets and emits separate labeled sections only
if every target can render the schema. Any unsupported feature fails atomically
with empty standard output.
Add an INCLUDE covering index
Section titled “Add an INCLUDE covering index”Use include to keep payload columns in a covering index without making them
search keys. Ptah preserves the comma-separated order after trimming whitespace:
type AccountIndexes struct { //ptah:schema:index name="idx_accounts_email" fields="email" include="display_name,created_at" table="accounts" _ int}For PostgreSQL, YugabyteDB, CockroachDB, and the Spanner PostgreSQL dialect, the
annotation renders INCLUDE ("display_name", "created_at"). PostgreSQL accepts
the default, BTREE, and GIST access methods, plus SPGIST on PostgreSQL 14
and newer. YugabyteDB accepts the default and LSM; BTREE is its documented
alias for the default LSM and renders identically to the default. CockroachDB
accepts the default and BTREE, which is also its default, and refuses GIN
and GIST because both name an inverted index there and an inverted index
stores no payload. The Spanner PostgreSQL dialect accepts only the default.
Every other dialect rejects include before emitting SQL. Omit include when
there are no payload columns; a present list with an empty element is a parse
error.
CockroachDB spells the payload STORING in its own output, so an index written
with include on a table named accounts is reported by SHOW CREATE TABLE as
INDEX idx_accounts_email (email ASC) STORING (display_name). It is the same
index, and ptah db read describes it with INCLUDE again.
Add an INCLUDE covering constraint
Section titled “Add an INCLUDE covering constraint”A UNIQUE or PRIMARY KEY constraint takes the same include payload, on a
constraint annotation rather than an index one:
type Account struct { //ptah:schema:field name="email" type="VARCHAR(255)" not_null="true" Email string
//ptah:schema:field name="display_name" type="VARCHAR(255)" //ptah:schema:constraint name="uq_accounts_email" type="UNIQUE" table="accounts" columns="email" include="display_name" DisplayName string}PostgreSQL, YugabyteDB, and CockroachDB render
CONSTRAINT "uq_accounts_email" UNIQUE ("email") INCLUDE ("display_name").
CockroachDB stores it as a unique index with a STORING clause, which is its
spelling of the same payload.
A covering PRIMARY KEY is narrower: PostgreSQL and YugabyteDB take it, and
CockroachDB does not.
Every other target refuses the render, naming the constraint and the targets that accept it:
error: error rendering mysql schema: mysql does not support INCLUDE columns on UNIQUE constraint "uq_accounts_email"; target postgres, yugabytedb, or cockroachdbThe targets for a constraint are not the targets for an index. The Spanner
PostgreSQL dialect takes include on an index and refuses it on a constraint,
and CockroachDB takes it on an index and on a UNIQUE constraint but not on a
primary key. Pick the object first, then read its list.
Install a PostgreSQL extension in a schema
Section titled “Install a PostgreSQL extension in a schema”An extension annotation belongs on a type declaration. Set schema when the
extension must live outside PostgreSQL’s default namespace:
//ptah:schema:extension name="pgcrypto" schema="extensions" if_not_exists="true"type PostgreSQLExtensions struct{}Ptah creates extensions first and renders CREATE EXTENSION ... WITH SCHEMA extensions. The same installation schema survives parsing from HCL or YAML,
Go-to-HCL export, live inspection, comparison, and a later apply.
Render and generate migrations from Go structs
Section titled “Render and generate migrations from Go structs”Everything from here is the same for every source, and lives once on
Work with a desired schema: rendering the SQL a
source produces, comparing it with a live database, gating a pipeline on
drift, composing several sources, and validating across dialects. The Go form
of the flag is --root-dir, which is repeatable and mixes freely with
--schema-file:
ptah schema compare --root-dir ./models --db-url "$DATABASE_URL"ptah migrations generate --root-dir ./models --db-url "$DATABASE_URL" --migrations-dir ./migrationsptah schema apply --root-dir ./models --db-url "$DATABASE_URL"One thing is worth rendering more than once when the schema is Go annotations, because the annotations are meant to be portable and a mapping surprise is easier to see than to reason about:
ptah schema render --root-dir ./models --dialect postgres >/tmp/schema.pg.sqlptah schema render --root-dir ./models --dialect mysql >/tmp/schema.mysql.sqlDialect differences are expected — enum storage, serial columns, generated columns. What the two renders check is that each target produces valid SQL for the capabilities it has.
Move the schema to HCL
Section titled “Move the schema to HCL”Start with a non-destructive export:
ptah schema export \ --from go \ --to hcl \ --root-dir ./models \ --out schema.hclPtah parses the generated HCL and verifies that its canonical re-render is
stable before it writes schema.hcl. Every valid Go annotation semantic has an
HCL representation. Function, view, materialized-view, and trigger bodies are
emitted as opaque HCL strings, and Ptah reports a warning for each because it
does not structurally parse those dialect-specific SQL sub-languages. Review
every warning before treating the export as semantically complete. A separate
diagnostic reports any source string whose bytes change during Unicode NFC
normalization.
One export captures the complete selected Go source set and uses that immutable view for both HCL parsing and cleanup planning. Ptah rechecks source membership, file identity, permissions, and contents before publishing the HCL; a concurrent source change aborts the export. The output directory is bound before staging, and Ptah also rechecks an existing output’s identity, permissions, and contents. An output creation, edit, or replacement detected at this commit barrier is left untouched and aborts publication. Successful HCL replacement is flushed to durable storage before Go annotation cleanup starts.
Preview annotation removal only after the export has no diagnostics:
ptah schema export \ --from go \ --to hcl \ --root-dir ./models \ --out schema.hcl \ --cleanup-go-annotations \ --cleanup-diffThe diff mode writes the validated HCL file but does not modify Go source. Run
the same command without --cleanup-diff to apply the prevalidated cleanup
plan.
Before publishing HCL, cleanup accounts for every recognized standalone Ptah
directive in the captured Go AST. Each directive must use a placement listed in
the Go annotation reference and must produce
the corresponding parsed schema object. A misplaced role/function directive or
a file-scoped RLS directive that resolves to no RLS object stops the operation
with its source file and line; neither the HCL output nor Go sources change.
Comments that only share a prefix with a directive, such as
//ptah:schema:tableau, are ordinary comments and remain byte-for-byte intact.
Next steps
Section titled “Next steps”- Looking up a directive or attribute? Go annotation reference.
- Modeling in files instead of Go? YAML schema, HCL schema, or SQL schema.
- Embedding the parser in your own tool? Public API.