Type-safe SQL control layer for Go 1.25+. Generic Table[T], composable expression builder, explicit JOINs with FK auto-join, composite indexes, and safe-by-default migrations — for mysql, sqlite, and postgres.
No interface{}/reflect in return types. Full parameterization through a shared expr.Writer (continuous placeholder counter). Savepoint-based nested transactions. ~10% overhead vs hand-written db.Query + Scan.
package main
import (
"context"
"log"
sqlkit "github.com/GlshchnkLx/go-sqlkit"
_ "modernc.org/sqlite"
)
type User struct {
ID int64 `sql:"id,pk,autoincr"`
Name string `sql:"name"`
Age int `sql:"age"`
}
func main() {
db, err := sqlkit.Open("sqlite", ":memory:", "sqlite")
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
users, _ := sqlkit.Register[User](db, sqlkit.WithTableName("users"))
create, _ := sqlkit.NewCreate[User](sqlkit.WithTableName("users"))
db.Exec(ctx, create.WithIfNotExists())
ins, _ := sqlkit.NewInsert[User](sqlkit.WithTableName("users"))
db.Exec(ctx, ins.Rows(&User{Name: "Alice", Age: 30}))
got, _ := sqlkit.Query[User](db).OrderBy(users.Field("id")).First(ctx)
log.Printf("user: %+v", got)
}go get github.com/GlshchnkLx/go-sqlkitImport drivers for side effects:
import (
_ "github.com/go-sql-driver/mysql" // mysql
_ "github.com/lib/pq" // postgres
_ "modernc.org/sqlite" // sqlite (pure Go, no cgo)
)sql struct tags — define table metadata inline:
type Membership struct {
TenantID int64 `sql:"tenant_id,pk,index=by_tenant_role"`
UserID int64 `sql:"user_id,pk,index=by_tenant_role"`
Role string `sql:"role"`
}Tags support: pk, autoincr, notnull, unique/unique=group, index/index=group, type=, default=, check=, fk=table.col, name=, old>new (rename detection).
Table options: WithTableName, WithPrimaryKey, WithIndex, WithIndexMethod, WithUnique, WithIndexPrefix, WithUniquePrefix.
Database options: WithDatabaseIndexPrefix, WithDatabaseUniquePrefix, WithDatabaseScanCacheSize.
Expression builder — composable WHERE/SET predicates:
sqlkit.Query[User](db).
Where(expr.And(
expr.Gt(users.Field("age"), 18),
expr.Or(
expr.Like(users.Field("name"), "A%"),
expr.Eq(users.Field("role"), "admin"),
),
)).
All(ctx)Includes Eq/Ne/Gt/Lt/Ge/Le, Like/NotLike, In/NotIn, Between, IsNull, Regexp, And/Or/Not, Match (&struct → AND equalities), RawExpr, subqueries (Exists/Any/All/Subquery).
Typed DML/DDL builders:
| Builder | Features |
|---|---|
Create[T] |
WithIfNotExists, IndexStatements() |
Insert[T] |
Multi-row, upsert (OnConflict), Returning (pg) |
Update[T] |
Set, Where/WhereAnd/WhereOr, All(), Returning (pg) |
Delete[T] |
Where/WhereAnd/WhereOr, All(), Returning (pg) |
Select |
From/FromSubquery, Join, GroupBy/Having, OrderBy, Limit/Offset, ForUpdate/ForShare, Union/Intersect/Except, Distinct |
Replace[T] |
MySQL REPLACE INTO, SQLite INSERT OR REPLACE |
Auto-join — embed registered structs, auto-resolve FK JOINs:
type UserWithProfile struct {
User
Profile
}
rows, _ := sqlkit.Query[UserWithProfile](db).FromAuto().All(ctx)Many-to-many via Through:
sqlkit.Query[PostWithTags](db).
FromAuto().
Through("post_tags", "post_id", "tag_id").
All(ctx)Safe-by-default migrations — auto-diff applies safe changes (add table, add nullable column, add/drop index/unique) and refuses risky ones (drop, type change, PK change) — those require a file migration:
db.RegisterMigration(migrate.File{
Version: 1,
Up: func(ctx context.Context, tx *sql.Tx) error { /* ... */ },
Down: func(ctx context.Context, tx *sql.Tx) error { /* ... */ },
})
res, err := db.Migrate(ctx) // files + auto-diff in one transactionNested transactions via savepoints:
tx, _ := db.Begin(ctx)
nested, _ := tx.Begin(ctx) // SAVEPOINT
nested.Rollback(ctx) // ROLLBACK TO SAVEPOINT — outer survives
tx.Commit(ctx)Dialects:
| SQLite | MySQL | Postgres | |
|---|---|---|---|
| Placeholder | ? |
? |
$1, $2, … |
| Quote | "…" |
`…` |
"…" |
| Autoincrement | AUTOINCREMENT |
AUTO_INCREMENT |
GENERATED BY DEFAULT AS IDENTITY |
time.Time → |
TEXT |
DATETIME |
TIMESTAMPTZ |
[]byte → |
BLOB |
BLOB |
BYTEA |
[16]byte (UUID) → |
BLOB |
BINARY(16) |
UUID |
| REPLACE | INSERT OR REPLACE |
REPLACE INTO |
❌ |
| RETURNING | ❌ | ❌ | ✅ |
Custom type mapping: dialect.RegisterCustomType(reflect.TypeFor[MyUUID](), "UUID")
Cached scan plan — LRU cache keyed by (T, columns), typed buffers, no reflection per row.
Sentinel errors checked via errors.Is:
| Sentinel | When |
|---|---|
ErrTableNotFound |
Table not registered |
ErrTableInvalid |
Invalid table definition |
ErrNoRows |
One/First returned 0 rows |
ErrMoreThanRequested |
One returned >1 row |
ErrBuilderInvalid |
Misconfigured builder (empty SET, no FROM, etc.) |
ErrTransportNotSupported |
Operation needs *sql.DB, Transport doesn't provide it |
ErrConflictingOption |
Conflicting migration options |
Typed errors via errors.As: *ErrTableInvalidError (Field/Opt/Reason/Cause), *ErrMigrationRequiresFile (Change/Hint).
Run with go run -tags examples ./examples/<name>:
| Example | Demonstrates |
|---|---|
basic |
Open, Register, Create, Insert, Query |
expr |
Eq/And/Or, Gt/Like/In, Match, RawExpr |
join |
Explicit JOIN, FK auto-join, many-to-many via Through |
composite |
Composite PK + index (tags and WithIndex) |
migrate |
Auto-diff + file migration up/down + dry-run |
transaction |
Nested savepoints |
postgres |
$N placeholders, IDENTITY (env-gated) |
upsert |
OnConflictDoNothing / OnConflictDoUpdate |
union |
Union / UnionAll / Intersect / Except |
returning |
Postgres RETURNING on Insert/Update/Delete |
lock |
ForUpdate / ForShare / NOWAIT / SKIP LOCKED |
custom-type |
RegisterCustomType + UUID + json.RawMessage |
demo |
Full CLI todo-list app |
go test ./... # in-memory SQLite
go test -race ./... # with race detector
go test -cover ./... # coverage report
go vet ./... # static analysis
go test -bench=. ./... # benchmarksMySQL/Postgres via env vars: SQLKIT_TEST_MYSQL_DSN=... / SQLKIT_TEST_POSTGRES_DSN=...
Coverage by package: sqlkit 92.8%, dialect 81.6%, table 90.2%, query 45.0%, expr 46.8%, migrate 51.4%.
Detailed docs in docs/: architecture, testing, error handling, file migrations, dialect/expr/migrate packages, mental map.
Apache License 2.0 — see LICENSE.