Keep your SQL as plain SQL in Go code, and have go vet prove it matches your schema: result
columns against your structs, parameters, NULL handling, single-row queries, and the constraint
violations each write has to handle, all checked against schema.sql before the code runs.
Supports PostgreSQL 17 / 18 and MySQL 8.4 on Go 1.26+. Checking needs no database and no C compiler, and the sqlshape code your program links is Apache-2.0.
// Row type, then parameter type. The checker proves both fit the SQL, and that it returns at most one row.
var ByEmail = sqlshape.One[User, struct{ Email string }](`
SELECT id, email, name, deleted_at FROM users WHERE email = {{.Email}}`)
u, err := postgres.Get(ctx, db, ByEmail, struct{ Email string }{Email: email}) // or mysql.Get- Mismatches are vet errors, not runtime surprises. A column the struct cannot hold, a
parameter of the wrong type, a nullable column read into a non-pointer, or a
Onequery that could return two rows is ago vetdiagnostic, with a quick fix where one exists. - Failure modes are declared and proved. Every write lists the constraints it can violate
(
-- sqlshape: expect users_email_key). The checker computes the real list fromschema.sql, including triggers and called functions, and reports both missing and impossible entries. At run timepostgres.Violates(err, "users_email_key")matches the same name: declaration, check and handler are one string. - The SQL stays plain, and nothing can be injected into it. Templates are Go
text/template.{{.X}}always becomes a bound parameter, every{{if}}/{{range}}combination is checked, and the runtime refuses any rendering the checker never saw (within the limits in Limitations). schema.sqlis the only definition. Views, functions, domains, row-level security and seeded lookup tables are all checked.sqlshape diffderives the migration from the same file, so there are no migration files to keep in sync.- The rules are the database's own. Statements are parsed by PostgreSQL's and MySQL's own parsers, and the verdicts are cross-checked against each database's regression and test suites (Compatibility).
Unlike sqlc, nothing is generated: you write the structs (or let sqlshape -fix write them from
the SQL), and the checker keeps them honest as the schema changes. Unlike an ORM, there is no
model that copies the schema. Constraint names, nullability and cardinality come from
schema.sql itself, which is how the checker can tell you which errors a write can raise.
The checker and the migration commands are one binary:
$ go install github.com/kr9ly/sqlshape/cmd/sqlshape/v2@latest
$ sqlshape version
Or take a prebuilt binary from the releases page:
Linux, macOS and Windows, amd64 and arm64 each, as sqlshape_<version>_<os>_<arch>.tar.gz (.zip
on Windows) with checksums.txt beside them. Unpack and put sqlshape on your PATH.
Then add the declarations and the runtime for your database to your module. The declarations
(sqlshape.Query, sqlshape.One) have no dependencies, and each runtime is a module of its own,
so an application pulls in only its own driver:
$ go get github.com/kr9ly/sqlshape/v2 # Query / One: the declarations the checker reads
$ go get github.com/kr9ly/sqlshape/postgres/v2 # running them on pgx v5
$ go get github.com/kr9ly/sqlshape/mysql/v2 # or on MySQL, through database/sql and go-sql-driver/mysql
Every module needs Go 1.26 or newer. Checking needs nothing else: no database, no C compiler. The first run takes about a second to prepare the embedded parsers and caches the result (how the checker runs).
Pick your database: PostgreSQL · MySQL
Put schema.sql at the root of your module. The checker uses the nearest schema.sql (or
schema/ directory) above each package, and -schema PATH overrides that.
-- sqlshape: postgres 17
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
name text NOT NULL,
deleted_at timestamptz
);The first line names the PostgreSQL version the schema is written for. Every statement is judged
with that version's grammar and catalog (postgres 17 or postgres 18).
Then declare the statements, here in users.go:
package app
import (
"time"
"github.com/kr9ly/sqlshape/v2"
)
type User struct {
ID int64
Email string
Name string
DeletedAt time.Time
}
var Users = sqlshape.Query[User, struct{ Name *string }](`
SELECT id, email, name, deleted_at FROM users
WHERE true {{if .Name}} AND name = {{.Name}} {{end}}`)
var Create = sqlshape.One[int64, struct{ Email, Name string }](`
INSERT INTO users (email, name) VALUES ({{.Email}}, {{.Name}}) RETURNING id`)Query[R, P] and One[R, P] take the row type and the parameter type. WHERE true lets the
optional AND clause follow; the checker checks the statement both with and without it
(docs/templates.md).
$ sqlshape ./...
users.go:16:13: sqlshape: field DeletedAt is time.Time but column "deleted_at" may be NULL (use a pointer, or tag it `col:",notnull"` if you know better)
users.go:20:65: sqlshape: may violate users_email_key (UNIQUE (email) on users, SQLSTATE 23505); add `-- sqlshape: expect users_email_key` to the template or make it impossible
Paths are shortened here; the checker prints them in full. The first diagnostic says deleted_at can be NULL but the field cannot hold NULL. The second says
the INSERT can violate the unique constraint on email and does not declare it. Fix both, and the
package is clean:
type User struct {
// ...
DeletedAt *time.Time
}
var Create = sqlshape.One[int64, struct{ Email, Name string }](`
-- sqlshape: expect users_email_key
INSERT INTO users (email, name) VALUES ({{.Email}}, {{.Name}}) RETURNING id`)At run time, with github.com/kr9ly/sqlshape/postgres/v2:
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
users, err := postgres.Collect(ctx, pool, Users, struct{ Name *string }{}) // []User
id, err := postgres.Get(ctx, pool, Create, struct{ Email, Name string }{e, n}) // int64
if postgres.Violates(err, "users_email_key") { /* the declared failure mode */ }Collect gathers every row and Get runs a One statement and returns its row. Run streams
rows, First takes the first, Exec runs a statement and discards its rows, and ExecOne runs
a One statement that returns no rows
(docs/runtime.md). pool is anything pgx gives you: a
*pgxpool.Pool, *pgx.Conn or pgx.Tx (docs/postgres.md).
Put schema.sql at the root of your module. The checker uses the nearest schema.sql (or
schema/ directory) above each package, and -schema PATH overrides that.
-- sqlshape: mysql 8.4
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
deleted_at DATETIME(6),
UNIQUE KEY users_email_key (email)
);The first line names the MySQL version. MySQL's own grammar parses the schema and every statement,
and MySQL's rules type them (mysql 8.4). A -- sqlshape: server line declares a sql_mode or
lower_case_table_names the server runs with, when it is not the default.
Then declare the statements, here in users.go:
package app
import (
"time"
"github.com/kr9ly/sqlshape/v2"
)
type User struct {
ID uint64
Email string
Name string
DeletedAt time.Time
}
var Users = sqlshape.Query[User, struct{ Name *string }](`
SELECT id, email, name, deleted_at FROM users
WHERE true {{if .Name}} AND name = {{.Name}} {{end}}`)
var Create = sqlshape.Query[struct{}, struct{ Email, Name string }](`
INSERT INTO users (email, name) VALUES ({{.Email}}, {{.Name}})`)Query[R, P] takes the row type and the parameter type; an INSERT returns no rows, so its row
type is struct{}. WHERE true lets the optional AND clause follow; the checker checks the
statement both with and without it (docs/templates.md).
$ sqlshape ./...
users.go:16:13: sqlshape: field DeletedAt is time.Time but column "deleted_at" may be NULL (use a pointer, or tag it `col:",notnull"` if you know better)
users.go:20:70: sqlshape: may violate users_email_key (UNIQUE users_email_key (email) on users, MySQL error 1062); add `-- sqlshape: expect users_email_key` to the template or make it impossible
Paths are shortened here; the checker prints them in full. The first diagnostic says deleted_at can be NULL but the field cannot hold NULL. The second says
the INSERT can violate users_email_key and does not declare it. Fix both, and the package is
clean:
type User struct {
// ...
DeletedAt *time.Time
}
var Create = sqlshape.Query[struct{}, struct{ Email, Name string }](`
-- sqlshape: expect users_email_key
INSERT INTO users (email, name) VALUES ({{.Email}}, {{.Name}})`)At run time, with github.com/kr9ly/sqlshape/mysql/v2 and the driver imported
(_ "github.com/go-sql-driver/mysql"):
db, err := sql.Open("mysql", "app:secret@tcp(localhost:3306)/app?parseTime=true")
users, err := mysql.Collect(ctx, db, Users, struct{ Name *string }{}) // []User
res, err := mysql.Exec(ctx, db, Create, struct{ Email, Name string }{e, n}) // sql.Result
if mysql.Violates(err, "users_email_key") { /* the declared failure mode */ }Collect gathers every row and Exec runs a write and returns the driver's sql.Result. Run
streams rows, First takes the first, and Get / ExecOne run One statements
(docs/runtime.md). db is a *sql.DB, *sql.Tx or *sql.Conn
opened with go-sql-driver/mysql; the DSN needs parseTime=true (docs/mysql.md).
You do not have to write the structs. Declare type Row struct{} and type Params struct{}
empty, write the SQL, and every column and parameter without a field is reported with a quick fix
that writes the struct from the SQL; sqlshape -fix ./... applies them all.
To check on every save, pass the binary to go vet as the -vettool. Any editor whose Go
integration runs go vet on save then shows the diagnostics:
$ go vet -vettool="$(which sqlshape)" ./...
In CI, run sqlshape ./...: it exits non-zero when it reports anything. Pin the version you
install, since a minor release may add diagnostics
(Versioning):
$ go install github.com/kr9ly/sqlshape/cmd/sqlshape/v2@v2.0.0
$ sqlshape ./...
Details in docs/flags.md.
The full list is in docs/checks.md. Broadly:
-
Shapes: the columns a statement returns and the Go struct that receives them, and the
{{.X}}parameters and the struct that supplies them, agree in name, type and NULL handling. Nested rows and composite types included. -
Meaning: if
OrderIDandUserIDare bothint64, passing a user ID where an order ID goes is still reported. A Go type used for an enum or lookup-table value, a primary key or a domain is bound to the column it stands for, so adding domains of different units, or constants that drift from the labels, are reported too. -
Failure modes: a write must declare the constraints it can violate on its expect line, and both a missing declaration and an impossible one are reported. The list is computed from
schema.sql(triggers and called functions included), and the names are the database's own (PostgreSQL's constraint names and SQLSTATEs, MySQL's key names and error numbers). -
Cardinality: a statement declared with
Oneis proved from the schema to return at most one row. -
Boundaries: team rules that show up in the shape of the SQL, such as always filtering soft-deleted rows, always pinning the tenant column, or reading tables only through views, are declared in
schema.sqland enforced by the checker:-- sqlshape: require pinned(tenant_id) -- sqlshape: visible where deleted_at IS NULL CREATE TABLE orders (...);
-
The schema itself: the functions (SQL and PL/pgSQL bodies), views and policies in
schema.sqlare type-checked too, and-strictadds advice such as a predicate no index serves or an enum a lookup table would serve better.
The rules schema.sql declares are not limited to Go code. sqlshape check judges any SQL against
them -- an operator's UPDATE before it runs in production, a backfill mixed into a migration, a
query an LLM agent is about to execute -- and prints every judgment and why each rule passed,
failed or was waived, so the output is also the record of what a script was allowed to do:
$ sqlshape check ops.sql
ops.sql:2: ok orders: require pinned(tenant_id)
ops.sql:6: waived orders: require pinned(tenant_id): orders: `require pinned(tenant_id)` is waived by this statement
ops.sql:8: FAIL orders: visible where deleted_at IS NULL: rows of orders are visible where ...
sqlshape: 2 finding(s)
A statement waives a rule with its own -- sqlshape: waive line. Rules can also be scoped to a
caller: -context ops applies the rules schema.sql declares for operators
(-- sqlshape: context ops: ...). Details in
docs/checks.md.
There are no migration files. Edit schema.sql, and sqlshape derives the DDL from the
difference between it and the database:
$ sqlshape diff -db "$DSN" > up.sql # DDL from the database's state to schema.sql
$ $EDITOR up.sql # reorder, split, add USING, interleave a backfill
$ sqlshape apply -db "$DSN" -packages ./... up.sql
$ sqlshape verify-schema -db "$DSN" # drift: where a database differs from schema.sql
The generated DDL may be edited by hand. apply checks, before running anything, that applying the
DDL really leads to schema.sql, and refuses otherwise. With -packages it also refuses while Go
code still uses a column the DDL drops or retypes. Changes whose intent a diff cannot infer, such as a
rename or the removal of an enum label, are declared in schema.sql with -- @migrate lines.
The migration commands are the only ones that run a real database. On PostgreSQL they download the declared version's server binaries into the user cache on first use, so that first run needs network access; on MySQL they use a scratch database on the server they migrate. See docs/migrations.md.
Four stages of the same order-management schema, from plain tables to putting more logic in the database, plus the first stage on MySQL:
examples/1-tables— The basic form:Query/Oneagainst tables, structs matched to the SQL, the constraints a write can violate declared on its expect lineexamples/2-views— Reading through views: joins and column names decided once in the schema, so the application's SQL gets thinnerexamples/3-database-api— Writes as functions, meaning as domains and composite types: how the checks work once logic lives in the databaseexamples/4-everything— Every feature in one place; the index to look up how a particular feature is used
On MySQL:
examples/5-mysql— The same declarations against MySQL:schema.sqldeclaresmysql 8.4, the statements run throughsqlshape/mysqlon database/sql
- The verdicts are measured against PostgreSQL 17 and 18 and MySQL 8.4, and
schema.sqldeclares one of them (-- sqlshape: postgres 17,postgres 18ormysql 8.4); any other number, such aspostgres 16ormysql 8.0, is refused. - A server of another version still works with sqlshape: the checker runs, and your program runs on any server its driver connects to, including older PostgreSQL releases, other MySQL versions, MariaDB and other MySQL-compatible databases. Statements are then judged by the declared version's rules, so where that server behaves differently, the verdicts are not guaranteed to match what it does.
- One database per project.
schema.sqldeclares which, and the grammar, the types, the constraint names in the diagnostics and the runtime module all follow from that declaration. - A template must be a Go string constant. SQL assembled at run time cannot be declared; the
parts that vary are
{{if}}/{{range}}branches, and the runtime refuses any rendering the checker never saw. A{{range}}is checked for up to two elements, and a template with more than 256 branch combinations only for a representative set; for those, the runtime confirms only the shape of the branches before running (docs/runtime.md). - Some PostgreSQL features have no MySQL counterpart and are not checked there (
Copy,MatView, PL/pgSQL, domains, composite types and arrays, and more; docs/mysql.md).
| Database | Versions | Grammar | Verified against |
|---|---|---|---|
| PostgreSQL | 17, 18 | libpg_query of the declared version | PostgreSQL's regression suite, on a real server |
| MySQL | 8.4 | MySQL's own parser and lexer, from the server source | a running mysqld and MySQL's test corpus |
PostgreSQL: the analyzer is built from the declared version's catalog. The verdicts are checked against PostgreSQL's own regression suite, run through the analyzer and a real server side by side: on 17 they disagree on 19 of 22,103 statements, on 18 on 31 of 23,384, every one listed and explained (docs/postgres.md).
MySQL: the function catalog comes from the same server source as the parser. The verdicts are
checked against a running mysqld: the result types of every built-in function, the error
statements, the ONLY_FULL_GROUP_BY check and the sql_mode variants agree with 8.4, and MySQL's
own test corpus (mysql-test/t, some 137,000 statements) replays against the analyzer and the
server side by side -- the 2,525 statements they knowingly disagree on are pinned one by one as a
baseline, and a new disagreement fails the build
(docs/mysql.md).
The migrations are judged the same way on both databases: generated schema pairs, every kind of change the diff can report, run as DDL against a real server with rows in its tables (docs/migrations.md).
The parsers (PostgreSQL's libpg_query per supported major version, MySQL 8.4's own grammar and
lexer) are embedded as WebAssembly and run on wazero, so there is no C compiler to install and no
library to link. The first run compiles the module (about a second) and caches the result under
the user cache directory (~/.cache/sqlshape on Linux).
Versions follow semantic versioning; every module of the repository is tagged together
(vX.Y.Z, postgres/vX.Y.Z, ...). Within a major version, the exported API of these modules, the
template syntax, the directives and the checker's flags stay compatible. A minor release may add
diagnostics, so code that passed before can be reported after an upgrade: pin the sqlshape
version in CI and upgrade deliberately.
docs/postgres.md and docs/mysql.md each gather what is specific to one database; the rest of the documentation is written for both.
- docs/postgres.md — everything PostgreSQL's: the version declaration, what the checker embeds and how it is verified, the runtime on pgx (
Batch,Copy,MatView, type registration), migrations on PostgreSQL - docs/mysql.md — everything MySQL's: the version and
serverdeclarations, the Go type table, constraint names and error numbers, theONLY_FULL_GROUP_BYcheck, the runtime ondatabase/sql, migrations on MySQL - docs/mysql-errors.md — the MySQL error numbers the checker predicts from a statement's own form, with an index by number: stored values, constant expressions, trigger and routine bodies, name resolution, views
- docs/checks.md — everything the checker verifies, for both databases: shapes, meaning, failure modes, cardinality, the rules a schema declares (every
schema.sqldirective,require, aggregates,sqlshape check) - docs/templates.md — the template subset, the directives a template carries, shared fragments, hazards, sparse checking
- docs/runtime.md — what every runtime does:
Run/Collect/First/Exec,One, row mapping, errors, the guarantee that only checked SQL runs, and what a runtime of your own does not get - docs/migrations.md — what to have before you start,
diff/apply/verify-schema,-- @migratedeclarations, several environments and rolling back, seeded tables, what differs on MySQL - docs/flags.md — every flag, the
-strictadvisories, editor setup - docs/design.md — design decisions: what was decided, why, and what was rejected (Japanese)
The only sqlshape code your program links is Apache License 2.0 (LICENSE): the
declarations (the root module) and the runtimes (postgres, mysql). The drivers the runtimes
pull in keep their own licenses (pgx: MIT, go-sql-driver/mysql: MPL-2.0). The PostgreSQL side of
the checker (check/postgres) embeds pg_catalog data and validation rules ported from
PostgreSQL under the PostgreSQL License, see check/postgres/NOTICE. The
sqlshape binary (cmd/sqlshape) and the MySQL side of the checker (check/mysql, which carries
MySQL's own parser) are modules under the GNU General Public License v2, see
cmd/sqlshape/LICENSE; the binary is a development tool, and nothing under
it is linked into your program.