nql is a compact Go package for building SQL queries with bound arguments.
It provides mutable builders for SELECT, INSERT, UPDATE, DELETE, plus
helper expressions for predicates, aliases, nested queries, and CASE
expressions.
The package was inspired by
github.com/Masterminds/squirrel.
The goal here was to keep a similar query-builder style while avoiding
reflection and avoiding wrappers for query execution.
The package follows a simple contract: every builder implements Sqlizer,
which renders SQL text and the corresponding []any argument list through
ToSql(). This keeps query construction composable without coupling the
package to a specific database driver or ORM.
SelectBuilderforSELECTqueries with support for prefixes, joins,WHERE,GROUP BY,HAVING,ORDER BY,LIMIT,OFFSET, suffixes, and subqueries inFROM.InsertBuilderforINSERTandREPLACE, including multi-rowVALUES,INSERT ... SELECT, suffixes, and deterministicSetMapcolumn ordering.UpdateBuilderforUPDATEwithSET,FROM,WHERE,ORDER BY,LIMIT,OFFSET, suffixes, and subqueries in assignments orFROM.DeleteBuilderforDELETEwithWHERE,ORDER BY,LIMIT,OFFSET, prefixes, and suffixes.- Expression helpers such as
Expr,ConcatExpr,Alias,Case,Eq,NotEq,In,NotIn,Like,ILike,Lt,Gt,And, andOr. - Placeholder rewriting from
?to dialect-friendly forms:?,$1,:1,@p1.
- Builders are mutable and accumulate state across method calls. Chained calls
update the same builder instance instead of returning a copy, so reusing a
builder keeps appending or replacing its existing state. Use
Clone()when you need to branch from an existing builder without mutating the original. - Nested builders are supported through the shared
Sqlizerinterface. - Map-based predicates and
SetMapuse sorted keys, so generated SQL is deterministic. - Placeholder conversion happens only at the outermost query level, which makes nested builders safe to compose.
- The package is intentionally lightweight: it only generates SQL and args, and leaves execution to the caller.
Note: builder methods mutate the receiver. Reusing a builder after one chain
continues modifying that same query state. Use Clone() when you need
branching behavior:
base := nql.Select("id").From("users").Where("active = ?", true)
latest := base.Clone().OrderBy("created_at DESC").Limit(10)
oldest := base.Clone().OrderBy("created_at ASC").Limit(10)Clone() copies builder-owned slice state so later appends on either builder do
not affect the other. Nested Sqlizer values are still shared unless you
replace them explicitly.
sql, args, err := nql.Select("u.id", "u.name").
From("users u").
LeftJoin("profiles p ON p.user_id = u.id").
Where(nql.Eq{"u.active": true}).
Where(nql.In{"u.role": []any{"admin", "editor"}}).
OrderBy("u.id DESC").
Limit(20).
PlaceholderFormat(nql.Dollar).
ToSql()Result:
SELECT u.id, u.name
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.active = $1 AND u.role IN ($2,$3)
ORDER BY u.id DESC
LIMIT 20Arguments:
[]any{true, "admin", "editor"}The helper types are designed to cover common WHERE and HAVING cases
without writing raw SQL for every comparison:
EqandNotEqrender equality and null checks.InandNotInrender list predicates.Like,NotLike,ILike,NotILikerender string matching predicates.Lt,LtOrEq,Gt,GtOrEqrender comparison predicates.AndandOrcombine multipleSqlizerexpressions into grouped logical conditions.
Expr(sql, args...) lets you write a raw SQL fragment with bound args. If one
of the args is itself a Sqlizer, Expr inlines that nested SQL and appends
its args instead of binding the nested value as a single argument.
sql, args, err := nql.Expr(
"COALESCE(?, ?)",
nql.Expr("nickname"),
"unknown",
).ToSql()Result:
COALESCE(nickname, ?)Arguments:
[]any{"unknown"}A double question mark is treated as an escaped literal question mark during
placeholder rewriting. This is useful when the SQL text itself needs a ?
token. At the Expr stage, escaped ?? stays in the SQL text:
sql, args, err := nql.Expr("count(??)", nql.Expr("x")).ToSql()Result:
count(??)Arguments:
[]any{nql.Expr("x")}Placeholder conversion happens only at the outer builder level. The default
format is Question, and the package also provides Dollar, Colon, and
AtP. Nested builders are rendered in raw form first, then placeholders are
rewritten once by the outer builder. During that final rewrite, escaped ??
collapses to a literal ? instead of becoming a numbered placeholder.
If your application consistently uses one placeholder style, configure it once at startup and new builders will pick it up automatically:
func main() {
nql.SetDefaultPlaceholderFormat(nql.Dollar)
}Per-builder PlaceholderFormat(...) still overrides the package default when a
specific query needs a different placeholder style.
- The package does not validate SQL identifiers or protect from malformed raw fragments passed as strings.
EqandNotEqreject list values; useInandNotIninstead.Likeand comparison helpers rejectniland list values.- Builders return errors when required parts are missing, for example no
SELECTcolumns, noINSERTvalues, or noUPDATESETclause. MustSql()is available for convenience, but it panics on invalid builder state.
nql fits projects that need a small SQL builder without the weight of a full
ORM. It is suitable when queries should remain explicit SQL, but you still want
safe argument binding, composable subqueries, stable output, and reusable query
fragments in plain Go code.
Benchmarks in this repository reflect the goal of keeping a query-builder style
similar to squirrel while avoiding reflection and query-execution wrappers.
On darwin/arm64 with Apple M3, go test -bench . -benchmem produced the
following results for equivalent query-building scenarios:
| Scenario | squirrel ns/op |
nql ns/op |
squirrel B/op |
nql B/op |
squirrel allocs/op |
nql allocs/op |
|---|---|---|---|---|---|---|
SelectSimple |
1779 | 350.3 | 2440 | 752 | 42 | 15 |
SelectComplex |
8857 | 1764 | 11157 | 3865 | 205 | 67 |
InsertMultiRow |
3123 | 788.7 | 4041 | 1504 | 80 | 31 |
UpdateWithCase |
3893 | 1022 | 5362 | 2288 | 102 | 41 |
DeleteSimple |
1340 | 157.3 | 1579 | 275 | 31 | 8 |
PlaceholderRewriteDollar |
3215 | 667.4 | 4131 | 1232 | 78 | 28 |
These numbers are environment-specific, but they show the current baseline for
the reflection-free implementation in this repository against the same
benchmark scenarios implemented with squirrel.