sqlr — a tiny, SQL-first builder & mapper for Go

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.
Features:
- 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 (...), (...), ... with bound args; place it after VALUES in the SQL.
- Plays well with handcrafted SQL (CTEs, JSON ops, window functions…).
- No runtime dependencies: the production package imports only the standard library. The test suite uses
go-sqlmock.
- Performance-minded: single-pass parser, sync.Pool builders, cached struct plans, careful allocation.
- Safe binding by design: values passed through
Bind become driver parameters and are never interpolated into SQL. Fragments passed to Write/Writef are raw SQL and must be trusted.
- Concurrency: share one *SQLR across goroutines; each *Builder is single-use.
Installation:
go get github.com/gandaldf/sqlr@latest
Examples:
Quick start
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)
}
}
Execute a statement
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()
Read a single scalar
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)
One row exactly
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.
Bulk insert
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 the name is arbitrary. It is a regular named parameter with a column list in curly braces, not a keyword.
Expansion in action
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.
IN (...) slice expansion
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]
VALUES :rows{...} bulk expansion
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"]
Prevent slice expansion (keep one placeholder)
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.
Scalar controls only sqlr's expansion behavior; it does not encode the value for a database driver. The standard database/sql conversion accepts []byte, but not arbitrary slices such as []int64. When executing the query, use a driver-supported value or a driver.Valuer such as pq.Array(ids).
Scalar binding via struct tag
// 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"`
}
f := Filter{IDs: []int{1, 2, 3}, Active: true}
q, args, err := sqlr.New(sqlr.Postgres).
Write(`SELECT id FROM users WHERE id = ANY(:ids) AND active = :active`).
Bind(f). // struct tags control binding behavior
Build()
if err != nil { return err }
_ = q
_ = args // contains the []int as one argument
The ,scalar option on the db tag tells sqlr not to expand the slice; it remains one placeholder whose value is the whole slice. As with Scalar, executing this example requires a driver that accepts that value type. A field whose value implements driver.Valuer is already treated as scalar automatically.
driver.Valuer (Postgres array)
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)
Valuer + Scanner (JSONB round-trip)
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.
Dynamic composition + Writef()
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().
Conditional composition & many Bind() calls
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
- Key/value Bind calls write into a small reusable bag owned by the builder. A one-argument Bind (map, struct, or rows slice) is queued as a source and resolved at Build time, without copying the source.
- Bind sources retain their exact call order, so last-write-wins also holds when key/value pairs, maps, and structs are interleaved.
- 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·S + E) in the general case, with an O(1) lookup fast path for the common final
map[string]any/pair bag, where:
- L = SQL length scanned once,
- H = number of placeholders,
- S = number of queued Bind sources (usually one),
- E = total items produced by expansions (IN (:ids), :rows{...}, etc).
- Struct reflection is backed by a bounded field-index cache; 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.
JOIN into two structs with overlapping field names
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)
Alternatives to Bind("k", v)
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{}), maps with string-compatible keys, or a struct using db tags or field names. Sources are retained in call order without being copied, can be mixed freely, and follow last-write-wins when keys overlap.
Bind a param map with P{}
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)
Bind a generic map
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)
ExecContext with timeout
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 }
ScanAllContext with cancellation
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 }
ScanOneContext with deadline
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 }
Configuration and parser limits
Pass one Config to New when the defaults are not appropriate:
s := sqlr.New(sqlr.SQLite, sqlr.Config{
MaxParams: 500,
MaxNameLen: 48,
})
MaxParams == 0 selects the dialect default; a negative value disables the limit. MaxNameLen <= 0 selects the default of 64 bytes.
| Dialect |
Default MaxParams |
| PostgreSQL |
65,535 |
| MySQL |
65,535 |
| SQLite |
999 |
| SQL Server |
2,100 |
sqlr returns ErrTooManyParams before emitting a query that exceeds the configured limit. It does not automatically split or chunk a statement.
Scanning behavior
ScanOne requires a non-nil pointer. Primitive and sql.Scanner destinations require exactly one result column; structs use column names and db tags.
ScanOne returns sql.ErrNoRows for zero rows. If more than one row is returned, it scans the first row into the destination and then returns ErrMoreThanOneRow.
ScanAll requires a non-nil pointer to a slice. It resets the slice length to zero and reuses existing capacity when possible; it does not append to the previous logical contents.
- Primitive and
sql.Scanner element types in ScanAll require exactly one result column.
- Result columns with no matching struct field are scanned and discarded. Struct fields with no matching result column are not assigned; when reusing a preallocated
[]T, do not rely on their previous contents being cleared.
- Nested structs are flattened.
time.Time and sql.Scanner types are leaves. The db:",scalar" option affects binding only and does not change scan mapping.
- SQL
NULL maps naturally to pointer fields and sql.Null*/custom Scanner fields. Scanning NULL into a non-nullable value normally returns a driver scan error.
Builder lifecycle & SQLR reuse
Build, Exec, ScanOne, and ScanAll 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.
Don’t reuse after Exec/Build
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 ...") // DON'T: b is released
Inspect, then execute (Preview doesn’t release)
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
Start fresh when you need a new query
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 }
Transactions
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()
Gotchas & tips:
- The
*SQLR instance is reusable and thread-safe across the app; each Write returns a single-use builder obtained from an internal pool.
- Builder lifecycle:
Build, Exec, ScanOne, and ScanAll release the builder to the pool. Don’t reuse it afterward. Use Preview to inspect without releasing.
- Raw SQL: both
Write and Writef append trusted SQL text. Only values supplied through Bind are parameterized; never concatenate untrusted identifiers or values into either method.
- 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, sql.Null*, or a custom sql.Scanner.
- Quotes/comments are respected:
:not_a_param inside string literals, quoted identifiers, comments, or dollar-quoted blocks is ignored. Dollar quoting is recognized in every dialect mode, although it is primarily PostgreSQL syntax.
Errors are exported sentinel values and can be checked with errors.Is: ErrParamMissing, ErrSliceEmpty, ErrRowsEmpty, ErrRowsMalformed, ErrColumnNotFound, ErrTooManyParams, ErrParamNameTooLong, ErrFieldAmbiguous, ErrBuilderReleased, and ErrMoreThanOneRow.
Benchmarks:
BenchmarkBind_Short_AllDialects/postgres-10 2429439 495 ns/op 432 B/op 4 allocs/op
BenchmarkBind_Short_AllDialects/mysql-10 2478878 484 ns/op 432 B/op 4 allocs/op
BenchmarkBind_Short_AllDialects/sqlite-10 2466078 485 ns/op 432 B/op 4 allocs/op
BenchmarkBind_Short_AllDialects/sqlserver-10 2423385 495 ns/op 432 B/op 4 allocs/op
BenchmarkBind_Medium_AllDialects/postgres-10 849217 1395 ns/op 1344 B/op 14 allocs/op
BenchmarkBind_Medium_AllDialects/mysql-10 897124 1326 ns/op 1328 B/op 14 allocs/op
BenchmarkBind_Medium_AllDialects/sqlite-10 902784 1332 ns/op 1328 B/op 14 allocs/op
BenchmarkBind_Medium_AllDialects/sqlserver-10 843613 1398 ns/op 1344 B/op 14 allocs/op
BenchmarkBind_Long_AllDialects/postgres-10 29554 40473 ns/op 71175 B/op 265 allocs/op
BenchmarkBind_Long_AllDialects/mysql-10 36858 32529 ns/op 71112 B/op 265 allocs/op
BenchmarkBind_Long_AllDialects/sqlite-10 36677 32802 ns/op 71112 B/op 265 allocs/op
BenchmarkBind_Long_AllDialects/sqlserver-10 28665 41829 ns/op 71175 B/op 265 allocs/op
- Builders are pooled; scanning uses cached plans and reuses holders to minimize allocations.
- Small key/value bags are reused with bounded retention; oversized builder buffers are dropped before pooling.
- Field-index lookups are cached in a compact two-tier map.
- Benchmarks and fuzz tests in the repo guard performance and safety.
Contributing:
Issues and PRs are welcome — especially additional tests, micro-benchmarks, and dialect edge-cases.
License:
MIT (see LICENSE).