sqlr is a minimal SQL builder and result mapper designed to stay very close to the SQL you already write. It focuses on keeping things simple: turn :named placeholders into driver args, expand IN (...) automatically, support bulk VALUES, and scan rows into your structs efficiently — all without a heavy ORM or a fluent DSL.
- SQL-first, no DSL: you write the SQL, sqlr doesn’t invent a DSL; it just binds and scans.
- Multiple dialects: Postgres, MySQL, SQLite, SQL Server.
- Placeholder rendering per dialect: Postgres → $1,$2…; MySQL/SQLite → ?; SQL Server → @p1,@p2….
- Minimal API surface: New, Write/Writef, Bind, Preview/Build, Exec, ScanOne, ScanAll.
- Typed scans, fast: struct mapping via db tags or field names, nested struct flattening, pointer/null handling.
- Bulk insert made simple: :name{a,b,c} emits VALUES (...),(...),... with bound args.
- Plays well with handcrafted SQL (CTEs, JSON ops, window functions…).
- No external dependencies: only the standard library.
- Performance-minded: single-pass parser, sync.Pool builders, cached struct plans, careful allocation.
- Safe by design: values are never interpolated into SQL strings; everything is parameterized.
- Concurrency: share one *SQLR across goroutines; each *Builder is single-use.
go get github.com/gandaldf/sqlr@latest
package main
import (
"database/sql"
"log"
_ "github.com/lib/pq"
"github.com/gandaldf/sqlr"
)
type User struct {
ID int `db:"id"`
Name string `db:"name"`
}
func main() {
db, _ := sql.Open("postgres", "<dsn>")
var users []User
err := sqlr.New(sqlr.Postgres).
Write("SELECT id, name FROM users WHERE id IN (:ids) AND active=:active").
Bind("ids", []int{1,2,3}).
Bind("active", true). // later binds can add/override keys
ScanAll(db, &users)
if err != nil {
log.Fatal(err)
}
}
res, err := sqlr.New(sqlr.MySQL).
Write("UPDATE products SET price=:price WHERE id IN (:ids)").
Bind("price", 999, "ids", []int{7,8,9}).
Exec(db)
if err != nil { return err }
rows, _ := res.RowsAffected()
var count int
err := sqlr.New(sqlr.Postgres).
Write("SELECT COUNT(*) FROM orders WHERE customer_id=:c AND status=:s").
Bind("c", 42, "s", "paid").
ScanOne(db, &count)
var u User
err := sqlr.New(sqlr.Postgres).
Write("SELECT id, name FROM users WHERE email=:e").
Bind("e", email).
ScanOne(db, &u)
// returns sql.ErrNoRows if none; sqlr.ErrMoreThanOneRow if >1
type Audit struct {
CreatedAt time.Time `db:"created_at"`
}
type Row struct {
ID int `db:"id"`
Name string `db:"name"`
Note *string `db:"note"` // pointer handles NULL
Audit Audit
}
var out []Row
err := sqlr.New(sqlr.Postgres).
Write(`SELECT id, name, note, created_at FROM users WHERE active=:a`).
Bind("a", true).
ScanAll(db, &out)
- created_at maps into Audit.CreatedAt via flattening.
- Pointers become nil when the DB returns NULL.
type NewUser struct {
ID int `db:"id"`
Name string `db:"name"`
}
rows := []NewUser{{1,"Anna"},{2,"Luca"},{3,"Mia"}}
_, err := sqlr.New(sqlr.SQLite).
Write("INSERT INTO users (id,name) VALUES :batch{id,name}").
Bind("batch", rows).
Exec(db)
The placeholder is called :batch{...}
here but it's arbitrary, It's not a keyword, but just a regular named parameter with curly braces.
sqlr expands at build time based on your bound values. You write :named params; sqlr turns them into the right placeholders for the dialect, expands slices/rows, and builds the final args in one pass.
q, args, _ := sqlr.New(sqlr.Postgres).
Write("SELECT * FROM t WHERE id IN (:ids) AND active=:a").
Bind("ids", []int{10,11,12}).
Bind("a", true).
Preview()
// q (pretty-printed):
// SELECT * FROM t WHERE id IN ($1,$2,$3) AND active=$4
// args: [10 11 12 true]
type NewUser struct{ ID int `db:"id"`; Name string `db:"name"` }
rows := []NewUser{{1,"Anna"},{2,"Luca"},{3,"Mia"}}
q, args, _ := sqlr.New(sqlr.Postgres).
Write("INSERT INTO users (id,name) VALUES :rows{id,name}").
Bind("rows", rows).
Preview()
// q:
// INSERT INTO users (id,name) VALUES ($1,$2),($3,$4),($5,$6)
// args: [1 "Anna" 2 "Luca" 3 "Mia"]
ids := []int64{1,2,3}
_, _, _ = sqlr.New(sqlr.Postgres).
Write("SELECT * FROM t WHERE id = ANY(:ids)").
Bind("ids", sqlr.Scalar(ids)). // keeps a single param
Build()
Using a driver.Valuer (e.g. pq.Array(ids)) also prevents expansion.
// Bind a slice as a single scalar param using the ",scalar" option.
type Filter struct {
IDs []int `db:"ids,scalar"` // <- prevents expansion of :ids
Active bool `db:"active"`
}
var out []int
f := Filter{IDs: []int{1, 2, 3}, Active: true}
err := sqlr.New(sqlr.Postgres).
Write(`SELECT id FROM users WHERE id = ANY(:ids) AND active = :active`).
Bind(f). // struct tags control binding behavior
ScanAll(db, &out)
The ,scalar
option on the db tag tells sqlr not to expand the slice; it remains one placeholder whose value is the whole slice (or driver.Valuer).
import "github.com/lib/pq"
ids := []int64{1,2,3}
var out []int64
err := sqlr.New(sqlr.Postgres).
Write("SELECT id FROM users WHERE id = ANY(:ids)").
Bind("ids", pq.Array(ids)). // single placeholder; driver handles encoding
ScanAll(db, &out)
type JSONB map[string]any
func (j JSONB) Value() (driver.Value, error) { // driver.Valuer
b, err := json.Marshal(j)
return b, err
}
func (j *JSONB) Scan(src any) error { // sql.Scanner
switch v := src.(type) {
case []byte:
return json.Unmarshal(v, j)
case string:
return json.Unmarshal([]byte(v), j)
default:
return fmt.Errorf("unsupported: %T", src)
}
}
type Row struct {
Meta JSONB `db:"meta"`
}
var rows []Row
err := sqlr.New(sqlr.Postgres).
Write("SELECT meta FROM users WHERE active=:a").
Bind("a", true).
ScanAll(db, &rows)
In short: Valuer controls how a value is sent to the driver; Scanner controls how a column is read into your type. sqlr lets database/sql do its job here.
table := "audit_events" // trusted constant, not user input
b := sqlr.New(sqlr.Postgres).
Writef("/* tenant=%d */ ", tenantID). // annotate the query
Writef("SELECT id, ts, kind FROM %s WHERE ts >= :since", table).
Bind("since", time.Now().Add(-6*time.Hour))
sql, args, _ := b.Preview()
// Use Exec/Scan to run; Preview does not release the builder.
Writef() is for safe, non-user interpolation (comments, known identifiers). Never put untrusted values in Writef().
b := sqlr.New(sqlr.Postgres).
Write(`SELECT id, name, created_at FROM users WHERE 1=1`)
if namePrefix != "" {
b.Write(` AND name ILIKE :name_prefix`).
Bind("name_prefix", namePrefix+"%")
}
if len(ids) > 0 {
b.Write(` AND id IN (:ids)`).
Bind("ids", ids) // expands only at build time
}
if since != nil {
b.Write(` AND created_at >= :since`).
Bind("since", *since)
}
var users []User
if err := b.ScanAll(db, &users); err != nil { /* ... */ }
Why many Bind() calls are cheap
- Each Bind(...) simply writes keys into an internal bag (map[string]any) owned by the builder. Later binds with the same key overwrite the previous value (last-write-wins).
- There’s no SQL re-parse and no args slice churn on every Bind. The heavy work happens once at Build/Exec/Scan:
- single-pass SQL parse,
- placeholder numbering per dialect,
- slice/rows expansion,
- final []any allocation and fill.
- Complexity is roughly O(L + H + E) where:
- L = SQL length scanned once,
- H = number of placeholders resolved via O(1) map lookups,
- E = total items produced by expansions (IN (:ids), :rows{...}, etc).
- Only Bind(struct)/Bind(map) perform reflection or map iteration once per call to materialize/update the bag. Repeated Bind("k", v) pairs are essentially single map writes.
This design lets you compose queries freely with negligible per-bind overhead, while keeping all value interpolation strictly parameterized.
type User struct {
ID int `db:"u_id"` // note the alias-tag mapping
Name string `db:"u_name"`
}
type Order struct {
ID int `db:"o_id"` // overlaps on name "id", so we alias
Total float64 `db:"total"`
}
type Row struct {
User User
Order Order
}
var rows []Row
err := sqlr.New(sqlr.Postgres).
Write(`
SELECT
u.id AS u_id,
u.name AS u_name,
o.id AS o_id,
o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.status = :st
`).
Bind("st", "paid").
ScanAll(db, &rows)
When you have many parameters—or they already live in a struct/map—it’s often nicer to bind them in one shot instead of writing multiple Bind("k", v) calls. sqlr accepts a literal param map (P{}), any map[string]any, or a struct (using db tags or field names); all end up in the same internal bag, can be mixed freely, and follow last-write-wins when keys overlap.
err := sqlr.New(sqlr.Postgres).
Write("SELECT * FROM products WHERE brand=:b AND price<=:p").
Bind(sqlr.P{"b": "Acme", "p": 100}).
ScanAll(db, &out)
type Filter struct {
Brand string `db:"b"`
MaxP int `db:"p"`
}
f := Filter{"Acme", 100}
err := sqlr.New(sqlr.Postgres).
Write("SELECT * FROM products WHERE brand=:b AND price<=:p").
Bind(f).
ScanAll(db, &out)
m := map[string]any{"b": "Acme", "p": 100}
err := sqlr.New(sqlr.Postgres).
Write("SELECT * FROM products WHERE brand=:b AND price<=:p").
Bind(m).
ScanAll(db, &out)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
res, err := sqlr.New(sqlr.Postgres).
Write("UPDATE products SET price=:p WHERE id IN (:ids)").
Bind("p", 999, "ids", []int{7,8,9}).
ExecContext(ctx, db)
if err != nil { return err }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var users []User
err := sqlr.New(sqlr.Postgres).
Write("SELECT id, name FROM users WHERE active=:a").
Bind("a", true).
ScanAllContext(ctx, db, &users)
if err != nil { return err }
deadline := time.Now().Add(500 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
var count int
err := sqlr.New(sqlr.Postgres).
Write("SELECT COUNT(*) FROM orders WHERE status=:s").
Bind("s", "paid").
ScanOneContext(ctx, db, &count)
if err != nil { return err }
Build, Exec and Scan release the builder back to an internal pool. Don’t keep using it after those calls. Use Preview if you need to inspect without releasing.
b := sqlr.New(sqlr.Postgres).
Write("UPDATE t SET a=:a WHERE id=:id").
Bind("a", 1, "id", 7)
_, err := b.Exec(db) // releases b
if err != nil { return err }
// b.Write(" AND ...") // DONT'T: b is released
b := sqlr.New(sqlr.Postgres).
Write("SELECT * FROM t WHERE id IN (:ids)").
Bind("ids", []int{1,2,3})
q, args, _ := b.Preview() // still usable
_ = q; _ = args
var out []int
if err := b.ScanAll(db, &out); err != nil { /* ... */ } // releases here
b := sqlr.New(sqlr.Postgres)
// first query
if _, err := b.Write("DELETE FROM sessions WHERE user_id=:u").
Bind("u", userID).
Exec(db); err != nil { return err }
// second query → new builder
var user User
if err := b.Write("SELECT id,name FROM users WHERE id=:u").
Bind("u", userID).
ScanOne(db, &user); err != nil { return err }
b := sqlr.New(sqlr.Postgres)
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// 1) debit
if _, err := b.Write("UPDATE accounts SET balance=balance-:amt WHERE id=:id").
Bind("amt", 50, "id", 1001).
ExecContext(ctx, tx); err != nil { return err }
// 2) credit
if _, err := b.Write("UPDATE accounts SET balance=balance+:amt WHERE id=:id").
Bind("amt", 50, "id", 2002).
ExecContext(ctx, tx); err != nil { return err }
// 3) read something within the same tx
var total int
if err := b.Write("SELECT COUNT(*) FROM ledger WHERE ok=:ok").
Bind("ok", true).
ScanOneContext(ctx, tx, &total); err != nil { return err }
return tx.Commit()
- The *SQLR instance is reusable and thread-safe across the app; each Write() spawns a disposable builder that is released by Build, Exec or Scan.
- Builder lifecycle: Build, Exec, and Scan release the builder to an internal pool. Don’t reuse it afterward. Use Preview to inspect without releasing.
- Empty inputs:
- IN (:ids) with an empty slice → error (ErrSliceEmpty). Decide your own fallback (WHERE 1=0, omit the clause, etc.).
- :name{...} with an empty slice → error (ErrRowsEmpty).
- Missing binds: referencing :name that isn’t provided yields ErrParamMissing.
- Ambiguous mapping: two struct fields mapping to the same column name cause ErrFieldAmbiguous. Disambiguate with tags/aliases (as in the JOIN example).
- NULL into non-pointer: scanning NULL into a non-pointer field triggers a driver scan error. Use T or sql.Null.
- Quotes/comments are respected: :not_a_param inside string literals, comments, or Postgres dollar-quoted blocks is ignored.
- Writef() safety: only use with trusted literals (comments, known identifiers). Never pass user input to Writef().
BenchmarkBind_Short_AllDialects/postgres-10 2589922 464.7 ns/op 432 B/op 4 allocs/op
BenchmarkBind_Short_AllDialects/mysql-10 2671608 450.6 ns/op 432 B/op 4 allocs/op
BenchmarkBind_Short_AllDialects/sqlite-10 2670493 448.9 ns/op 432 B/op 4 allocs/op
BenchmarkBind_Short_AllDialects/sqlserver-10 2579859 467.0 ns/op 432 B/op 4 allocs/op
BenchmarkBind_Medium_AllDialects/postgres-10 785796 1487 ns/op 1296 B/op 20 allocs/op
BenchmarkBind_Medium_AllDialects/mysql-10 793989 1393 ns/op 1280 B/op 20 allocs/op
BenchmarkBind_Medium_AllDialects/sqlite-10 862000 1371 ns/op 1280 B/op 20 allocs/op
BenchmarkBind_Medium_AllDialects/sqlserver-10 797533 1501 ns/op 1296 B/op 20 allocs/op
BenchmarkBind_Long_AllDialects/postgres-10 17695 67829 ns/op 121531 B/op 534 allocs/op
BenchmarkBind_Long_AllDialects/mysql-10 23005 52208 ns/op 104049 B/op 532 allocs/op
BenchmarkBind_Long_AllDialects/sqlite-10 22969 52065 ns/op 104048 B/op 532 allocs/op
BenchmarkBind_Long_AllDialects/sqlserver-10 17173 70306 ns/op 133826 B/op 535 allocs/op
- Builders are pooled; scanning uses cached plans and reuses holders to minimize allocations.
- Field-index lookups are cached in a compact two-tier map.
- Benchmarks and fuzz tests in the repo guard performance and safety.
Issues and PRs are welcome — especially additional tests, micro-benchmarks, and dialect edge-cases.
MIT (see LICENSE).