Skip to content

sqlb

Declare your tables once. Get typed composable queries, a validated REST filter grammar, a TypeScript client, a CLI and domain hooks — without hand-writing the HTTP-to-SQL layer for every dynamic view.

A schema is ordinary Go values, and it is the source of truth for all of these. Each one is independently optional — a project can take the query builder and nothing else.

Schema

Tables as Go values: columns, references, indexes, constraints, and the capabilities that decide what the outside world can reach.

Declaring tables →

Queries & domain logic

A query is a value, so predicates compose on a branch. Hooks are where the rules live — one registration constrains every read of a model.

Queries →

REST API

List, read, create, patch and delete per exposed table, with filtering, sorting, search, pagination and an OpenAPI document built from the schema.

Mounting resources →

TypeScript SDK

where admits only filterable columns with the operators their type accepts, select narrows the response type, and a hidden column has no spelling at all.

TypeScript SDK →

Go CLI

A cobra command tree over the same vocabulary, so --help states what a resource accepts without sending a request — the form the guarantee has to take for a caller with no compile step.

Go CLI →

Migrations

A schema edit diffed into DDL your own runner applies — and an existing database read back into a schema file, which is the same machinery pointed the other way.

Migrations →

Static query generators cannot express “this WHERE clause exists only when the user typed something in the search box.” The usual workaround is string concatenation, which is why the HTTP layer of a filter/sort/search page is mostly boilerplate.

PostgREST solves that by making the database the API, but there is then nowhere to put Go domain logic, and the whole schema sits one policy mistake away from being public.

sqlb takes the middle path. A query is a value, so predicates can be added conditionally:

q := sqlb.Query[Post]().Where(sqlb.F("status").Eq("published"))
if search != "" {
q = q.Where(sqlb.F("title").Contains(search))
}
posts, err := q.OrderBy(sqlb.F("created_at").Desc()).Limit(50).All(ctx, db)

and the REST filter grammar compiles into that same predicate AST:

?status=eq.published&title=contains.postgres&sort=-created_at&per_page=50

One compiler, one bind-parameter discipline, one set of hooks — two producers. That is the whole design, and One grammar, two producers is the long version.

Capabilities are opt-in

Filterable, Sortable, Searchable, Hidden. A column that does not declare a capability cannot be reached through it — ever. The failure is a 400 naming what would have worked, not a leak.

Hooks are the domain seam

BeforeQuery receives the query itself, so one registration constrains every read of a model — including the ones generated REST handlers issue. Tenant scoping stops being something each call site remembers.

Nothing runs unasked

SQL() renders text and args without executing. Explain plans against the live schema without running. Diff returns changes as values; your runner applies them.

No dependencies to inherit

The engine depends on the standard library alone, and a check in CI enforces it. Only the REST adapter pulls in Huma, and only if you use it.

Six worked applications, each settling a different question — and each saying out loud what it deliberately is not.

sqlb is pre-1.0, has one author and no observed consumers. That is the honest starting position, and no amount of feature work substitutes for elapsed time under real traffic. See compatibility for what the current tag freezes and which surfaces are expected to move, and the adoption review for an outside read on what that means in practice.

What is proven, and re-checked on every run rather than asserted: CI applies the generated DDL to a real Postgres 18, reads it back with introspect, and requires the round trip to be a fixpoint; the query path runs through a real PgBouncer in transaction pooling, because that is the deployed topology; and the blog example is generated from its schema, so every behaviour test in it is also a test of the generator’s output.

Postgres only. LISTEN/NOTIFY, jsonb aggregation and RETURNING are all load-bearing; multi-dialect support would cost the best features.