
What it is
A small Go SQL query builder. It tries to fit between sqlc and GORM by giving you compile-time type checking on columns without a codegen step, and explicit Set/Unset semantics for partial UPDATE so zero values don't silently get dropped.
Built on database/sql, zero dependencies, works with *sql.DB and *sql.Tx.
Repo: https://github.com/gsql-dev/gsql
Why I'm asking
I started this as a learning project, not as a serious attempt to displace sqlc or bun. It's now at a point where I have to decide whether to keep growing it (subqueries, CTEs, aggregates) or just leave it as-is and move on to the next thing. Before spending another month or two on features, I'd rather hear from people actually shipping Go in production whether this fills a real gap, or whether the existing landscape already covers it.
What I want to know
- Is "type-safe but no codegen" something you'd actually use, or does sqlc cover it well enough for you?
- Does the Set / Unset thing solve a real problem in your code, or is it overkill?
- Happy to hear "you're solving a problem I don't have" too. That's also useful.
Honest weaknesses
NewTable() uses reflect once at startup per table to read the db:"..." tags. It's microseconds and the query path itself is reflection-free, but if your project has a hard "no reflect anywhere" rule, this isn't the right library for you.
Column names in tags are strings that the Go compiler can't verify against the real DB schema. Same trade-off every string-based builder has.
Not implemented yet: subqueries, CTEs, window functions, aggregates (COUNT / SUM / GROUP BY / HAVING), raw SQL escape hatch. On the list, not shipped.
Code sample
Define a table:
type UserColumns struct {
ID qb.Col[int64] `db:"id"`
Name qb.Col[string] `db:"name"`
Email qb.Col[string] `db:"email"`
Age qb.Col[int] `db:"age"`
}
var Users = qb.NewTable[UserColumns]("users")
Query (column types are checked at compile time, so Gt("18") won't build):
u := Users.Cols
q := qb.Select(u.ID, u.Name, u.Age).
From(Users).
Where(u.Age.Gt(18)).
OrderBy(u.Age.Desc()).
Limit(10)
sql, args := q.Build()
rows, err := db.QueryContext(ctx, sql, args...)
Partial update where empty string and zero are not the same as "don't touch":
qb.Update(Users).
Set(qb.ValIf(u.Name, qb.Set("Alice"))). // updated
Set(qb.ValIf(u.Email, qb.Set(""))). // updated to empty string on purpose
Set(qb.ValIf(u.Age, qb.Unset[int]())). // not in the SET clause
Where(u.ID.Eq(int64(1))).
Exec(ctx, db)