Skip to content
PtahPtah

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.

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.

The smallest annotation source that is still useful in a real project is a table, a primary key, and a unique constraint:

models/
account.go
migrations/

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:

Terminal window
ptah schema render --root-dir ./models --dialect postgres

Expected 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:

Terminal window
ptah schema render --root-dir ./models --dialect sqlite >/tmp/ptah-schema.sql
sed -n '1,80p' /tmp/ptah-schema.sql

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

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.

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 cockroachdb

The 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:

Terminal window
ptah schema compare --root-dir ./models --db-url "$DATABASE_URL"
ptah migrations generate --root-dir ./models --db-url "$DATABASE_URL" --migrations-dir ./migrations
ptah 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:

Terminal window
ptah schema render --root-dir ./models --dialect postgres >/tmp/schema.pg.sql
ptah schema render --root-dir ./models --dialect mysql >/tmp/schema.mysql.sql

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

Start with a non-destructive export:

Terminal window
ptah schema export \
--from go \
--to hcl \
--root-dir ./models \
--out schema.hcl

Ptah 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:

Terminal window
ptah schema export \
--from go \
--to hcl \
--root-dir ./models \
--out schema.hcl \
--cleanup-go-annotations \
--cleanup-diff

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