sqlpro is a small, reflection-based convenience layer over Go's
database/sql. It maps Go structs to rows, rewrites placeholders, expands IN
clauses, runs transactions with lifecycle hooks, and provides bulk
insert/update helpers — while staying out of your way: you always write the SQL.
It supports PostgreSQL (via pgx) and SQLite (via modernc.org/sqlite).
import "github.com/programmfabrik/sqlpro"
db, err := sqlpro.Open("postgres", "host=localhost dbname=app sslmode=disable")
// or: sqlpro.Open("sqlite", "/path/to/app.db?_pragma=journal_mode(WAL)")
type Author struct {
ID int64 `db:"id,pk,omitempty"`
Name string `db:"name"`
}
a := &Author{Name: "Ada"}
db.Insert("author", a) // INSERT; a.ID is filled in
var authors []*Author
db.Query(&authors, "SELECT * FROM author WHERE name LIKE ?", "A%")- Install
- Connecting
- Mapping structs to rows
- Reading
- Writing
- NULL, JSON and custom column types
- Placeholders & escaping
- Transactions
- Introspection
- Errors
- Examples
- Testing & benchmarks
go get github.com/programmfabrik/sqlproImport a driver somewhere in your program (sqlpro selects it by name):
import (
_ "modernc.org/sqlite" // for "sqlite"
// pgx is pulled in by sqlpro itself for "postgres"
)Open(driver, dsn) returns a DB. driver is "postgres", "sqlite" or
"sqlite3".
db, err := sqlpro.Open("sqlite", "/tmp/app.db")
defer db.Close()Open pings the connection and applies driver-appropriate defaults: PostgreSQL
uses $1 placeholders and RETURNING for generated keys; SQLite uses ?
placeholders and the RFC3339 time format. Everything below works the same on
both drivers.
db.DB() returns the underlying *sql.DB if you need something sqlpro does not
wrap.
Columns are matched to struct fields by the db tag. Only exported fields with
a db tag participate. Embedded structs are flattened, so their columns are
promoted as if declared inline.
type Row struct {
ID int64 `db:"id,pk,omitempty"`
Name string `db:"name"`
}Tag options (comma-separated after the column name):
| option | effect |
|---|---|
pk |
primary key — used as the WHERE for Update, and written back after Insert |
omitempty |
skip the column on write when the Go value is zero (lets the DB apply its default / auto-increment) |
readonly |
never written by sqlpro (server-generated/computed columns); still read back |
json |
the field is JSON-marshaled on write and unmarshaled on read |
json_ignore_error |
ignore JSON marshal/unmarshal errors for this field |
null |
for json fields: write SQL NULL (not the string "null") when the value is zero |
notnull |
for json fields: write the literal "null" rather than SQL NULL |
- |
ignore the field entirely (never read or written) |
db:"name" with no options just maps the column.
Query / QueryContext pick how to scan from the type of the target:
| target | result |
|---|---|
*int64, *string, *time.Time, … |
first column of the first row |
*struct |
the first row mapped by db tags |
*[]Struct, *[]*Struct |
all rows |
*[]int64, *[]*string, … |
the first column of all rows |
**sql.Rows |
the raw rows handle for manual iteration |
var n int64
db.Query(&n, "SELECT count(*) FROM author")
var a Author
db.Query(&a, "SELECT * FROM author WHERE id = ?", 1)
var all []*Author
db.Query(&all, "SELECT * FROM author ORDER BY name")
var names []string
db.Query(&names, "SELECT name FROM author")A single-row target that matches no row returns
ErrQueryReturnedZeroRows; a slice target simply comes back empty.
db.Insert("author", &a) // pk written back into a
db.InsertContext(ctx, "author", &a)
db.Update("author", &a) // by pk
db.Save("author", &a) // upsert: insert if pk==0, else update
db.Exec("DELETE FROM author WHERE id = ?", 1)
affected, lastID, err := db.ExecContextRowsAffected(ctx, "UPDATE author SET name = ?", "x")Bulk helpers operate on a slice of structs in one round-trip. On PostgreSQL,
InsertBulk uses COPY FROM for large batches; on SQLite it builds multi-row
INSERTs (chunked under the placeholder limit):
db.InsertBulk("author", []*Author{{Name: "a"}, {Name: "b"}})
db.InsertBulkOnConflictDoNothingContext(ctx, "author", rows, "name") // skip conflicts on "name"
db.UpdateBulkContext(ctx, "author", rows) // update many by pkBulk inserts do not read generated keys back into each struct;
SELECTthe rows afterwards if you need their ids.
- NULL: use a pointer field.
nil⇄ SQLNULL. - JSON: tag the field
db:"col,json"; it is stored as JSON text. json.RawMessage: stored/loaded verbatim.- Custom types: implement
driver.Valuer(write) andsql.Scanner(read).
type Place struct {
Name *string `db:"name"` // nullable
Config Settings `db:"config,json"` // JSON column
Raw json.RawMessage `db:"raw"` // stored as-is
At Geo `db:"at"` // Valuer + Scanner
}A custom column type implements both interfaces:
func (g Geo) Value() (driver.Value, error) { return fmt.Sprintf("%g,%g", g.Lat, g.Lng), nil }
func (g *Geo) Scan(v any) error { /* parse v into *g */ }The helper scanners NullTime, NullJson and NullRawMessage are used
internally and are exported for direct use with *sql.Rows.Scan.
Write portable ? placeholders; sqlpro rewrites them to $1, $2, … on
PostgreSQL. Special placeholders:
-
IN ?— pass a slice and it expands to the right number of placeholders:db.Query(&names, "SELECT name FROM author WHERE id IN ?", []int64{1, 2, 3})
-
@— the next argument is quoted as a SQL identifier (table/column):db.Query(&n, "SELECT count(*) FROM @", "author")
Escaping helpers for the rare cases a value can't be a bound argument:
db.EscValue("O'Hara") // 'O''Hara'
sqlpro.IlikeSql(db.Driver(), "berg") // driver-correct case-insensitive LIKE snippetExecTX is the recommended entry point. It opens a dedicated connection, hands
a transaction-carrying context to your job, commits on success and rolls
back on error or panic. Inside the job, get the transaction with CtxTX(ctx)
and use it exactly like a DB:
err := db.ExecTX(ctx, func(ctx context.Context) error {
tx := sqlpro.CtxTX(ctx)
if err := tx.Insert("account", &acc); err != nil {
return err // -> rollback
}
return tx.Exec("UPDATE ledger SET balance = balance - ? WHERE id = ?", amt, id)
}, nil) // *sql.TxOptions, or nilTransactions cannot be nested (ExecTX inside ExecTX errors). Lifecycle hooks
can be registered on the transaction:
| hook | when |
|---|---|
BeforeCommit(func() error) |
inside Commit, before the underlying commit; an error rolls back |
AfterCommit(func()) |
after a successful commit |
AfterRollback(func()) |
after a rollback |
AfterTransaction(func()) |
after commit or rollback |
Use BeforeCommit when a side effect must be atomic with the transaction (e.g.
bumping a cache version); use the After* hooks for non-transactional effects
(logging, cache invalidation).
For explicit control there are also Begin(), BeginRead() (read-only) and
BeginContext(), each returning a TX you Commit() / Rollback() yourself.
v, _ := db.Version() // e.g. "Sqlite 3.45.0" / PostgreSQL version string
n, _ := db.Name() // current database name / sqlite file
db.Log() // returns a copy with debug logging enabledErrQueryReturnedZeroRows— a single-rowQueryfound nothing. Test witherrors.Is.ErrMismatchedRowsAffected— an operation affected an unexpected number of rows.
A runnable, end-to-end tour lives in examples/. It exercises every
feature against a throwaway SQLite database:
go run ./examples| file | covers |
|---|---|
crud.go |
Insert / Query / Update / Save / Delete, query target shapes |
bulk_tags.go |
InsertBulk, on-conflict, UpdateBulk; pk/omitempty/readonly/-/embedding |
null_json.go |
nullable pointers, JSON columns, json.RawMessage, custom Valuer/Scanner, and the ,null/,notnull/,json_ignore_error null-handling options |
placeholders.go |
?, IN ?, @, EscValue, IlikeSql |
transactions.go |
ExecTX, hooks, rollback, BeginRead, introspection |
Most tests run against SQLite and need no setup:
go test ./... # everything except the PostgreSQL test
go test -run TestCopyFrom . # needs a local PostgreSQL "apitest" databasefeature_test.go is a from-scratch, feature-by-feature suite covering the full
public surface. The scan path has a benchmark:
go test -run='^$' -bench=BenchmarkScanRows -benchmem .The slice-of-struct read path is optimized to build its column plan, scan buffer and null-scanners once per query and reuse them across rows, so only the row struct itself is allocated per row.