A schema-first data layer for Go and Postgres: declare your tables once, get typed composable queries, a validated REST filter grammar, and domain hooks — without hand-writing the HTTP-to-SQL layer for every dynamic view.
Documentation · Quickstart · API reference · Decision records
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. One compiler, one bind-parameter discipline, one set of hooks — two producers.
- Capabilities are opt-in per column.
Filterable,Sortable,Searchable,Hidden. A column that does not declare a capability cannot be reached through it — ever, and the failure is a 400 naming what would have been accepted, not a leak. This is the difference between this and exposing the database. - Hooks are the domain seam.
BeforeQueryreceives the query itself, so one registration constrains every read of a model — including the reads that generated REST handlers issue. Tenant scoping stops being something each call site has to remember. - Paging that survives a write.
?cursor=names the position of the last row rather than counting to it, so page 500 costs what page 1 costs and a concurrent insert cannot make a client read a row twice. Every list response carries the cursor for the next page, so adopting it needs no flag. - Nothing runs unasked.
SQL()renders text and args without executing.Explainplans against the live schema without running it, so it also fails on the migration that was written and never applied — which a compile-time column check cannot.Diffreturns migration changes as values; your runner applies them. - The clients are generated from the schema too. A TypeScript client,
emitted into the repository that consumes it, where
whereadmits only filterable columns with the operators their type accepts,selectnarrows the response type, and a hidden column has no spelling at all. The OpenAPI document cannot say any of that —?status=eq.publisheddocuments asarray<string>— so it is generated from the model instead (guide). The same vocabulary reaches a Flutter app as Dart — plus the cursor pager an infinite-scrolling list needs, which is the piece a mobile client otherwise rebuilds out ofhas_moreand an offset counter (guide) — and Go, as a typed client that imports the standard library and nothing else, plus an optional cobra command tree over it: one flag per filterable column, its operators in the usage string, so--helpstates what a resource accepts without a request — which is the form the guarantee has to take for a caller with no compile step, such as an agent (guide). - One dependency, and it is the one you already have. The engine is written
on pgx and takes nothing else; a CI gate fails
on anything that is not pgx or something pgx itself pulls in. That is a
deliberate reversal — sqlb used to depend on the standard library alone, and
ADR-0040 says what it bought:
sqlb writes join a
pgx.Txyour own code opened, arrays need no codec, and pgvector's binary format is reachable. Only the REST adapter pulls in Huma, and only if you use it. The generated TypeScript, the generated Dart and the generated CLI are separate toolchains and separate opt-ins; the emitters produce text, socodegenitself takes nothing.
go get github.com/jryannel/sqlbGo 1.25 or newer, and Postgres. Quickstart goes from here to a running server.
The generator is a command, and the loop is one line each way:
go install github.com/jryannel/sqlb/cmd/sqlb@latest
sqlb generate ./schema # models, typed columns, REST bodies, manifest, clients
sqlb check ./schema # the CI drift gate: writes nothing, fails if stale
sqlb migrate -name adds_slug ./schema # the migration that closes the gap
sqlb eject ./schema # the way out: the schema as SQL, the resources as
# plain net/http handlers over pgx, importing no sqlbThe argument is the package that declares your schema, and the package says what
to emit and where by exporting one function. Because the schema is Go, sqlb
compiles a driver against your module to read it — see
ADR-0032 for why that is forced and what it
costs.
generate and check need no database. migrate works out the current schema
by replaying your committed migrations into a scratch Postgres, because reading
a live one tells you what the database looks like rather than whether the
migrations produce it — so it needs an empty database, except for the very first
migration, which diffs against nothing.
The schema DSL and code generation are both optional: sqlb.Describe[T]()
layers the same capabilities over structs you already have, including stock
sqlc output, without editing them. Moving one endpoint
across is worked in four stages, each a place
to stop, with a test that requires all four to return the same rows.
And the way out is generated too. sqlb eject writes a package that depends on
pgx and the standard library — your schema as DDL, your statements as SQL you
can read, your endpoints as net/http handlers — with everything it does not
carry refused by name rather than quietly missing.
example/blog/ejected is a committed one, and a test
serves it beside the generated resources it came from and compares the answers
request by request. See the way out.
Pre-1.0, one author, no observed consumers. That is the honest starting
position, and no amount of feature work substitutes for elapsed time under real
traffic. Compatibility
says what v0.1.0 freezes and which surfaces are expected to move.
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.
Not built yet, in the order they matter: a durable change feed, and an MCP server over the manifest. Vision has the detail.
| Start here | Overview, quickstart, a worked first app, structs-first adoption |
| Concepts | The five ideas the rest of it rests on |
| Schema · Queries · REST · TypeScript · Dart · CLI · Migrations | One section per surface |
| Examples | Six worked applications, and what each one proves |
| Reference | Filter operators, column types, capabilities, codegen options, CLI, rejections |
| Architecture | How the pieces fit, the request path, where safety lives |
| Decision records | What was decided, why, and what would change our mind |
example/recipes |
Eighty-odd small examples, one file per aspect — the place to look when you know what you are building and need to know how one piece is spelled |
example/blog |
A worked schema and everything codegen emits from it |
example/tasks |
A multi-tenant task manager: auth, migrations, a runnable server, and a generated TypeScript client, Dart client and CLI |
example/fxapp |
The same pieces assembled by uber-go/fx on the fxkit glue — copyable, not importable: hooks arriving through a value group, a pluggable auth module behind the principal seam, and a resource that refuses to mount without its hooks |
example/computed |
Four ways to get a derived value out of Postgres — generated columns, trigger counters, projected expressions, views — and where sqlb's ceiling is today (ADR-0041) |
example/evolve |
A schema that changed five times: what is free, what destroys data, and the rename that is a clean migration and a broken client at once (the walkthrough) |
example/withsqlc |
sqlb and sqlc over one schema, plus one list endpoint in four stages from static SQL to a generated resource |
mise run test # the inner loop; no Docker or Postgres needed
mise run ci # the full gate, same as .github/workflows/ci.yml
mise tasks # everything elseTool versions are pinned in mise.toml, so a green run locally and a green run
in CI use the same Go and the same linter. The engine's tests run against an
in-memory executor rather than a database, which keeps the inner loop fast;
test-pg answers what that cannot — whether the generated SQL is valid rather
than merely expected — and is part of ci.
CONTRIBUTING.md has what a change is expected to carry, and where to argue with a decision record rather than around it.
MIT — see LICENSE.