Skip to content

Declaring tables

A schema is ordinary Go values. That is what lets one declaration be the source of truth for migrations, models, REST handlers, generated clients and the OpenAPI document — there is no separate schema language to keep in sync, and no reflection over a database at startup.

package blogschema
import "github.com/jryannel/sqlb/schema"
var Post = schema.Table("posts",
schema.UUIDv7("id").PrimaryKey(),
schema.Ref("author", Author).OnDelete(schema.Restrict),
schema.Text("title").Searchable().Sortable(),
schema.Enum("status", "draft", "review", "published").
Default(schema.Value("draft")).
Filterable().
Sortable(),
schema.Timestamps(),
schema.SoftDelete(),
).
Index("author_id").
Check("published_posts_have_a_date", "status <> 'published' OR published_at IS NOT NULL").
Describe("A blog post.").
Expose(schema.REST{Ops: schema.CRUD | schema.OpList, MaxPageSize: 100})

schema.Table registers into the default registry as a side effect of declaration, which is why a codegen program only has to import the package.

This page covers the column vocabulary and the table-level constructs. Capabilities is what each column opts in to, and References and relations is how tables point at each other.

Constructor SQL type Go type
Text(name) text string
Varchar(name, n) varchar(n) string
SmallInt(name) smallint int16
Int(name) int int32
BigInt(name) bigint int64
Real(name) real float32
Float(name) / Numeric(name) float / numeric float64
Numeric(name, p, s) numeric(p, s) float64
Bool(name) bool bool
UUID(name) uuid string
Timestamp(name) timestamptz time.Time
Date(name) / Time(name) date / time time.Time
JSON(name) jsonb json.RawMessage
Bytes(name) bytea []byte
Enum(name, values...) text + a check constraint a named string type
SmallSerial(name) / Serial(name) / BigSerial(name) smallserial / serial / bigserial int16 / int32 / int64

Two shorthands cover the conventional cases. UUIDv7(name) is a UUID column defaulting to a generated, time-ordered v7 value — the usual primary key. Enum emits a Go string type with one constant per value, so blog.PostStatusPublished exists and a typo does not compile.

A serial is an integer whose value comes from a sequence, and it has two spellings in Postgres. The serial constructors above are the older one; .Identity() and .IdentityAlways() are the modern one, which is what Postgres now recommends because it has no separate sequence object to name:

schema.BigSerial("id").PrimaryKey() // bigserial
schema.BigInt("id").Identity().PrimaryKey() // GENERATED BY DEFAULT AS IDENTITY
schema.Int("attempt").IdentityAlways() // GENERATED ALWAYS AS IDENTITY

Prefer an identity for a new column; declare a serial when the database already has one, because moving between them is a migration rather than a rename — and adopting an existing database is most of why these exist. The two identity forms differ in one thing: BY DEFAULT still lets an INSERT name the column, which is what a data import or a backfill needs, while ALWAYS refuses it and is therefore marked read-only.

None of this is a type. A bigserial column is a bigint — the Go type, the filter operators and the sort machinery are the plain integer’s, and it reads back as one. What the declaration adds is that the database supplies the value, so an insert that does not name the column is not missing one.

The column cannot be nullable and cannot carry a Default(): the sequence is the default, and Postgres refuses both combinations. So does Validate(), before any DDL is written.

One thing a generated migration cannot do for you: when a column becomes a serial on a table that already has rows, the sequence starts at 1. The change says so in its hazard and names the setval to run first — the row count is not in the schema, so nothing here can size the scan that would compute it.

Nullable() allows SQL NULL and makes codegen emit the Go field as a pointer — so a nullable JSON column is a *json.RawMessage, and a nullable Timestamp a *time.Time.

The exceptions are the two types that can already express absence on their own. A Bytes column stays []byte and an Array() column stays a slice, because nil is what a slice is when the value is absent; a pointer would add a second spelling for the same thing. json.RawMessage is a slice too, but a document type rather than a bag of bytes, so it takes the pointer like everything else.

The full table, including which filter operators each type admits, is in the column type reference.

Array() is a modifier on any of the scalar constructors above, so a text[] is a text column that says so:

schema.Text("labels").Array().Filterable().Default(schema.Value("{}")),
schema.Enum("channels", "web", "email").Array().Nullable(),

The Go field is the plain slice — []string, not a wrapper type — and the generated TypeScript and Dart clients get string[] and List<String>, which is precisely what the same column declared as JSON could not give them.

The constructor keeps naming the element, and that is load-bearing rather than cosmetic: ?labels=has.urgent binds one text, so the enum’s value set and the varchar’s length stay attached to the thing that has them.

Nullability is about the column, not the elements. A NULL column and an empty array are different values, and the Go side spells them nil and []string{}; there is no way to declare an array whose elements may be NULL, because {a,NULL,b} and NULL are two absences no generated client could tell a UI apart.

Three rules, all reported by schema.Validate:

  • an array column cannot be Sortable — the keyset cursor encodes the ordering columns, and an array has no spelling in it;
  • it cannot be Searchable — search is a text operation;
  • a Filterable one must carry a GIN index, or every filter over it is a sequential scan that returns the right rows and reports nothing.
).AddIndex(schema.Index{Columns: []string{"labels"}, Method: "gin"})

Elements are the scalar types; not JSON, not Bytes, and one dimension only. Filtering one is has / hasany / hasall, and their n-prefixed negations.

A Computed column is an expression rather than storage. It emits no DDL — the table Postgres holds does not have it — and it is a column to everything above Postgres: it is in the row type, the JSON, the TypeScript and Dart types, the CLI’s columns and the OpenAPI document, and Filterable and Sortable gate it exactly as they gate a stored one.

schema.Computed("is_overdue", schema.TypeBool,
schema.FromSQL("due_date < current_date AND open_tasks > 0")).
Filterable(),
schema.Computed("total_tasks", schema.TypeInt,
schema.FromSQL("(SELECT count(*) FROM tasks t WHERE t.project_id = projects.id)")),
schema.Computed("is_starred", schema.TypeBool,
schema.FromSQL("EXISTS (SELECT 1 FROM stars s "+
"WHERE s.project_id = projects.id AND s.member_id = ?)")).
Needs("viewer").Filterable(),

The compiler substitutes the expression wherever the column is named, so one declaration reaches the projection, ?filter=is_overdue.eq.true and ?sort=-progress at once. The projection aliases it back to the column name, which is what lets the row scan into the field.

A computed column is nullable unless it says otherwise, which is the opposite of a stored one and the same as SQL. A correlated subquery that matches nothing is NULL, arithmetic over a nullable column is NULL, and a comparison against one is NULL — and there is no NOT NULL in any DDL for the generator to read the answer off, because there is no DDL. So the three declarations above generate *bool, *int32 and *bool, and NotNull() is how an expression that cannot produce one says so:

schema.Computed("total_tasks", schema.TypeInt,
schema.FromSQL("(SELECT count(*) FROM tasks t WHERE t.project_id = projects.id)")).
NotNull(), // count(*) is 0, never NULL

It is a claim rather than a check — nothing parses the SQL — so it belongs on the count(*), the EXISTS, and the comparison already guarded against its own nulls. The default runs the other way because that is the direction that fails safely: a pointer scans a non-null value fine, and the reverse is a 500 saying cannot scan NULL into *string, on rows a fixture is unlikely to contain (#147).

A subquery is projection-only unless you say otherwise. Writing Filterable() on one is the acknowledgement that a subquery in a WHERE runs once per candidate row. Searchable() says the same thing about ?search, and is allowed on a computed column whose declared type is text — which is the only way to search across a relation:

schema.Computed("participant_names", schema.TypeText,
schema.FromSQL("(SELECT string_agg(m.display_name, ' ') FROM members m "+
"WHERE m.id = ANY(chats.participant_ids))")).
Searchable(),

A chat is named in the UI by whoever is in it — a direct message has no name at all — so fanning out over the chat’s own columns finds nothing for exactly the rows a search is for, and answers 200 while doing it (#93).

Reading one is opt-in, because the model is shared. A computed column is declared on the model and usually wanted by one screen, so nothing projects it unless it asks:

sqlb.Query[Project]().WithComputed("total_tasks", "is_starred") // a query
rest.Options{Computed: []string{"total_tasks", "is_starred"}} // a resource

A generated resource opts into every computed column its table declares, so generated endpoints are unaffected — the opt-in exists for everything else reading the same model. Without it, three aggregates declared for a list screen attached a correlated subquery each to every read of the model, including this:

sqlb.Query[Project]().Where(sqlb.F("id").Eq(id)).One(ctx, db)

which is asking whether a row exists. Worse, a column declaring Needs made that query fail — it wanted a viewer bind the caller had no business supplying (#92).

For a resource the opt-in is a boundary rather than a projection setting: a computed column a resource does not select is not filterable, sortable or nameable in ?select there either. A filter on a correlated subquery costs what the projection would have, so being merely unprojected would not have made it cheap. The obligation follows the same line — a resource that selects a Needs column still refuses to mount without a hook to supply the bind, and one that does not select it never has to care.

Needs supplies what the request knows. Each ? takes the bind named at the matching position, and the value arrives with the query:

sqlb.On[Project](reg).BeforeQuery(func(ctx context.Context, q *sqlb.Builder[Project]) error {
q.Bind("viewer", memberFrom(ctx))
return nil
})

Like Scoped, the declaration writes no value — it obliges a hook. A resource whose binds nothing supplies does not mount, because an unbound expression would render member_id = NULL, answer false for every row forever, and look exactly like a feature that works. The bind is sent once however many times the expression appears in the statement.

Four rules, reported by schema.Validate:

  • a computed column cannot be Searchable?search fans out over text columns with ILIKE, and an expression has no reading there;
  • it cannot be Sortable if its expression is volatile — one reading now() or current_date is a different value on the next page, and the keyset cursor pages on the sort column;
  • it cannot be a primary key, unique, defaulted, a reference, indexed or an enum — each is a statement about storage;
  • Needs must name exactly as many binds as the expression takes.

Nothing parses the SQL: sqlb generate refuses the four rules above, but a typo inside the expression reaches Postgres, and Explain against a real database is what catches it early.

Nothing writes one: it is absent from the generated create and update bodies and from every INSERT. A write’s RETURNING can read one back, so a POST response carries it without a second read — but only the ones the caller asked for, with WithComputed on the statement or Computed on the resource. That is the same opt-in a read takes, and it is opt-in for the same reasons plus one: an aggregate evaluated by a create counts the rows that create has not written yet (#164). A parameterised one can never be read back — a write has no viewer to bind — so it is absent from the statement and from the write’s response, and arrives on the next read.

An index can never serve one, which is why a trigger-maintained counter or a GENERATED ALWAYS AS … STORED column is still the better answer when the value allows it; schema.Lint says so once per filterable computed column. example/computed is the five techniques side by side, and ADR-0041 is why the tiers are drawn where they are.

Nothing parses the expression, so nothing can refuse a subquery that reaches into another module — and the question to ask before writing one is not “is this a subquery” but whose table does it name.

A subquery over this module’s own tables is what the feature is for: a chat’s participant_ids over its own chat_members is correct and deletes an N+1. A subquery naming another module’s table is the coupling ExternalRef refuses to expand for, arriving through a door nothing guards.

It is tempting to reason that the coupling is the same as the LEFT JOIN being replaced. It is not, and the difference is the footprint. A join lives in one query behind one handler. A computed column travels with the model: it is selectable by every mount that opts into it, and it is in the RETURNING of every write that asks for it. A module that turned LEFT JOIN projects into Computed("project_name", FromSQL("(SELECT name FROM projects …)")) found the subquery in the RETURNING of every insert, so the table could not be written at all unless projects existed in the same database, and its isolation boot test failed on its own seed with relation "projects" does not exist. The column had to come back out of the declaration.

The answer is ExternalRef’s own: fetch the other side through that module’s API. sqlb cannot check this — resolving a table name out of raw SQL is exactly the dependency ExternalRef’s free-text target exists to avoid — which is why it is written down rather than enforced.

Timestamps() and SoftDelete() insert several columns as a unit:

schema.Timestamps() // created_at, updated_at — both default now(), read-only, sortable
schema.SoftDelete() // deleted_at — nullable, read-only

Factor your own recurring column sets the same way by returning a schema.Group.

SoftDelete adds a column and stops. Nothing writes deleted_at, nothing filters it out of reads, and the generated DELETE issues a real DELETE. Making it mean something is two pieces of your own — a BeforeQuery hook that adds the predicate, and an endpoint that stamps the column — and the schema knows to ask for the first: a resource over a soft-deleting model does not mount until a hook confines it. example/blog is that pair written out.

Every column has one spelling on the wire — the JSON body, the filter grammar’s parameter names, ?sort, the OpenAPI document and both generated clients. By default it is the column’s own name, so created_at is created_at everywhere.

A schema whose front end is camelCase says so once:

var Module = schema.NewModule("app").WireCase(schema.Camel)
// or, for a schema using the package-level Table():
func init() { schema.SetWireCase(schema.Camel) }

created_at is then createdAt in the body, in ?createdAt=gte.…, in ?sort=-createdAt, in the OpenAPI document and in both clients — and still created_at in the database, in every hand-written query and in pg_dump.

There is deliberately no per-column override. One setting, applied by one pure function, is what keeps the five surfaces from disagreeing; a per-column mapping is the part with a reason to drift (ADR-0036).

A case that cannot round-trip is refused at build time. snake → camel is not invertible over every name: pos_x_2 becomes posX2, which reads back as pos_x2. Validate computes both directions for every column and fails the schema naming the column and both spellings, so an ambiguity is a build error on a schema nobody has deployed rather than a wrong parameter name in a shipped client. Rename the column, or leave the schema Verbatim.

A CLI flag keeps its kebab-cased spelling either way — --created-at under both — because a flag is a local affordance rather than a wire format. What moves is the query parameter it sends.

).
Index("org_id", "status"). // composite
UniqueIndex("org_id", "slug").
IndexNamed("idx_posts_author", "author_id"). // the name the database has
AddIndex(schema.Index{Columns: []string{"body"}, Method: "gin"}).
Check("name", "status <> 'published' OR published_at IS NOT NULL")

An EXCLUDE constraint says no two rows may hold values pairwise related by the given operators. The canonical use is the one no application-level check can make safe:

AddExclude(schema.Exclusion{
Name: "bookings_no_double_booking",
Using: "gist",
Elements: "coach_id WITH =, tstzrange(starts_at, ends_at) WITH &&",
Where: "status = 'confirmed'",
})

One coach cannot hold two confirmed bookings whose time ranges overlap — enforced by the database, across concurrent transactions.

This is the one constraint with no near miss. A composite UNIQUE has a unique index; a composite primary key has a surrogate; smallint still round-trips at the wrong width. Dropping an exclusion has no equivalent at all: either the invariant moves into application code, where two concurrent requests interleave between the check and the insert, or the table stays outside the declaration and the drift gate holds a permanent known-difference exception.

Elements and Where are hand-written SQL, exactly as Check is, and for the same reason — Postgres stores a parse tree and renders it back in its own spelling, so a structured form would have to reproduce that spelling or every diff would propose replacing a constraint that had not changed. Both go through the probe in shadow.Normalize, which adds the real constraint to the shadow database and reads back what Postgres stored. Asking beats guessing.

Using is the index method, and it is almost always gist: the operators that make an exclusion useful — && over a range, and = over a scalar beside it — are gist’s. Pairing a scalar = with a range needs the btree_gist extension, which no generated DDL creates; sqlb introspect lists the extensions a database has so that is knowable before the first bootstrap rather than after it fails.

Field.PrimaryKey() declares a single-column key. When the key is a pair — an association table where the pair is the row, a natural-key cache keyed by what it describes — declare it on the table:

schema.Table("llmcatalog_models",
schema.Text("provider"),
schema.Text("model_id"),
schema.Text("display_name"),
).PrimaryKeyColumns("provider", "model_id")

The alternative was a schema change: a surrogate UUID that nothing points at, plus a unique index to make the real key unique — 16 bytes and an extra index per row, identifying something no other table references. That is a change nobody would defend if sqlb vanished tomorrow, which is the test an adopter applies to every line of a migration.

A composite-key table is not a resource, and the schema refuses to make it one. TableDef.PrimaryKey() returns nil for it, so it takes the keyless path everywhere row identity is assumed, and Validate refuses three things by name:

Why
REST exposure /{id} addresses one column, and so do the cursor and the generated cache key — each is a wire format (ADR-0034)
Being the target of Ref/ExternalRef A reference is single-column here too
A non-collection Action An id is one column

Those refusals are the design rather than a shortfall. What these tables needed was to be declarable, so that one of them stops taking its whole module out of the drift gate — the gate is per registry, and it is all-or-nothing. If such a table does need to be a resource, give it a surrogate key deliberately, which is then a decision rather than a tax.

UniqueIndex and Unique are different objects

Section titled “UniqueIndex and Unique are different objects”

Both enforce the same rule and they are not interchangeable. UNIQUE (a, b, c) written inline in CREATE TABLE produces a constraint; CREATE UNIQUE INDEX produces an index. Postgres builds an index either way, but only a constraint can be

  • the target of FOREIGN KEY … REFERENCES t (a, b), and
  • named in ON CONFLICT ON CONSTRAINT.

So when a table already has a constraint, declaring the index instead is not a spelling difference: it diffs as a different object, and adopting that table would propose dropping the constraint and building an index in its place — a real migration on live data, forced by the declaration language rather than by anything the schema needed. Unique and UniqueNamed are the table-level peers of Field.Unique(), and they are what an adoption reaches for, because the inline form is how a composite natural key is ordinarily written.

Unique names the constraint the way Postgres names one it generated itself — secrets_tenant_kind_tenant_id_name_key — rather than the way UniqueIndex names an index. Two conventions, deliberately: an index sqlb declares is one sqlb named, while a constraint is usually one that already exists under a name the application may be matching on.

By default, at the end of each statement. Deferrable moves it to COMMIT:

AddUnique(schema.Unique{
Columns: []string{"product_id", "option_signature"},
Deferrable: schema.DeferredCheck,
})

The case is a rule about the committed state that no single statement can satisfy. A product variant is identified by the combination of its option values; the values live in a child table and reference the variant, so the variant row is written first and passes through a state where its denormalised signature is still the default — and two variants of one product collide on that default at INSERT time. INITIALLY DEFERRED says exactly what is meant, and the alternatives weaken the rule (a partial index excluding the placeholder) or move it into application code, where two concurrent writers interleave between the check and the insert.

schema.DeferrableCheck is the third setting: deferrable, but immediate unless a transaction says SET CONSTRAINTS … DEFERRED. Field.Deferred() is the column-level spelling of DeferredCheck for a single-column constraint.

Deferral is declarable on UNIQUE and nothing else. On the other constraint kinds it is read and reported rather than silently dropped: introspection lists a deferred foreign key, primary key, check or exclusion as a construct the declaration cannot express, with its definition attached, so an adoption knows to keep the hand-written migration that put it there. That reporting is the point of #154 — before it, a deferred constraint was invisible to both sides of the round trip, so the fixpoint held because the declaration and the database were blind to the same property, and a migration that recreated the constraint without its clause would have passed the drift gate on its way to breaking every write.

ADR-0051 is the general rule that came out of it: where a layer below the declaration can say something the declaration cannot, the gap is reported rather than left to be found.

AddIndex takes a fully specified Index for what the shorthands do not cover — GIN indexes, partial indexes via Where, and per-column sort order via Orders. A partial unique index is often the cleanest way to state a domain rule the type system cannot: UNIQUE (book_id, borrower_id) WHERE returned_at IS NULL makes borrowing a book you already have out impossible, and borrowing it again next year ordinary.

Orders is what an index backing a specific ORDER BY needs, because there the ordering is the index — one built for ORDER BY position ASC NULLS FIRST, created_at DESC serves nothing else:

AddIndex(schema.Index{
Name: "idx_tasks_project_position",
Columns: []string{"project_id", "position", "created_at"},
Orders: map[string]schema.IndexOrder{
"position": {Nulls: schema.NullsFirst},
"created_at": {Desc: true},
},
})

An absent entry is ascending with Postgres’s default null placement, and a placement that already follows from the direction is dropped when the DDL is rendered — so a declaration and what pg_get_indexdef hands back agree, and two spellings of the same order do not propose replacing each other.

A partial index’s Where is hand-written SQL, and Postgres stores it as a parse tree rather than as text: latitude IS NOT NULL comes back as (latitude IS NOT NULL). sqlb migrate puts the declared predicate through the same normalisation before diffing (shadow.Normalize), so you write it the way you would say it rather than the way Postgres would print it.

Index and UniqueIndex name the index by convention — posts_org_id_idx, posts_org_id_slug_uniq. IndexNamed and UniqueIndexNamed take the name instead, which is what describing a database somebody else’s tool built needs: a declared index whose name differs from the live one is a rename, and across a schema of any size that turns adoption into renaming every index in the database.

An index name is not always inert, which is the sharper half. Postgres reports a violated constraint by name, and matching that name is the standard way to tell one unique violation from another —

pgErr.Code == "23505" && pgErr.ConstraintName == "idx_projects_org_code"

— so renaming a unique index turns a handled collision into an unhandled 500 without touching the code that handled it. The generated migration says so when it proposes one.

An external reference gets an index whether or not you asked for one. It is resolved when the index set is read, so an index you declare on the same column replaces it rather than colliding with it, and it shows up in Indexes(), the manifest and the generated DDL like any other.

Check is the floor under everything else. A hook is a convention; a check constraint cannot be bypassed by code that has not been written yet — see where domain logic goes.

Expose is what publishes a table over HTTP. Without it, the table is reachable from Go and has no REST surface at all.

Expose(schema.REST{
Path: "/posts",
Ops: schema.OpCreate | schema.OpRead | schema.OpUpdate | schema.OpList,
DefaultPageSize: 20,
MaxPageSize: 100,
MaxFilters: 12,
MaxSortTerms: 4,
MaxOffset: 10_000,
DefaultSort: []string{"-pinned", "-published_at"},
})

schema.CRUD is create, read, update and delete together; OpList is separate because a table can be readable by id without being listable. Leaving an operation out means the endpoint does not exist — not that it answers 405.

Five of these are the per-request cost ceilings, and each is worth setting per resource: they are the bounds on what one request may ask the database to do, and the numbers that justify them — the row count, the width of the table — are known here. MaxPageSize is a hard ceiling rather than a hint; MaxFilters and MaxSortTerms bound how many predicates and sort terms one request may carry; MaxOffset bounds how deep ?page= may reach. A zero takes the package default. See Pagination.

DefaultSort is not a ceiling. It says what a list request that names no ?sort returns — the ordering the collection means, rather than the primary-key order that is what silence used to fall back to. Terms are column names, most significant first, with a leading - for descending, and each must declare Sortable. ?sort replaces it; the primary-key tiebreak is appended either way, so cursors are unaffected. It reaches the OpenAPI description, the manifest, the generated skill and the ejected handlers, which is the point: the alternative is a constant in one hand-maintained SDK facade that no other client and no agent reading the spec ever sees.

A registry is the unit of isolation. Independent modules each declare into their own, so two of them may both own a table called events:

var Billing = schema.NewModule("billing")
var Invoice = Billing.Table("invoices", …) // → billing_invoices

The prefix is applied by the registry rather than written into each declaration, which is the point: a convention repeated at every call site is one that drifts. Declarations still use the local name, so moving a table between modules changes one line. The URL keeps the local name too — a module prefix is a storage concern, and leaking it into the API would make that move a breaking change.

Across a module boundary, use ExternalRef; see References.

Two different questions, two different calls.

Validate() — is this schema well-formed? Every authoring mistake is reported at once rather than one per run. Call it from a test, or let codegen fail.

Lint() — will this schema behave badly in production? Problems that compile fine and produce a bad database or a bad API:

for _, d := range reg.Lint() {
fmt.Println(d)
}
[warn] unindexed-filter: events.kind: column is filterable but is not the leading column of any index, so filtering on it scans the table
fix: add .Index("kind") to the table, or drop .Filterable() from the column
[info] list-without-sort: events: list endpoint has no sortable column, so every client gets the same primary-key order and none can ask for another
fix: mark at least one column .Sortable(), conventionally created_at
[info] no-max-page-size: events: no MaxPageSize, so the package default applies as the hard ceiling
fix: set MaxPageSize on the REST exposure to a value this table can serve

That output is from ExampleRegistry_Lint in schema/example_test.go, so it is what the linter actually says. Both are worth running from a test — the loop here is go test, not a CLI.