Zero-boilerplate SQL for Go. Define a struct with tags. That's it.
If you write Go and talk to SQL databases, you know the pain. Every CRUD operation requires writing a raw SQL string, carefully mapping columns to struct fields with rows.Scan, manually wrapping writes in Begin/Commit/Rollback, and keeping your schema definitions in sync with your code. It's tedious, error-prone, and the boilerplate never ends.
This is the story of sqlh — a library I built to eliminate all of that, while staying in the "sweet spot" between raw SQL (too much work) and heavy ORMs (too much magic).
The Problem: Go + SQL = Death by a Thousand rows.Scans
Go's database/sql package is excellent. It gives you a solid, portable foundation for talking to any SQL database. But it intentionally leaves the hard work to you.
Here's what a simple CRUD workflow looks like with raw database/sql:
// 1. Create table — raw DDL string
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
email TEXT,
age INTEGER
)`)
// 2. Insert — explicit placeholders and args
_, err = db.Exec(
"INSERT INTO user (name, email, age) VALUES (?, ?, ?)",
"Alice", "alice@example.com", 30,
)
// 3. Get by ID — QueryRow + manual Scan
var u User
err = db.QueryRow("SELECT id, name, email, age FROM user WHERE id = ?", 1).
Scan(&u.ID, &u.Name, &u.Email, &u.Age)
// 4. List all — Query + rows.Next + rows.Scan loop
rows, err := db.Query("SELECT id, name, email, age FROM user ORDER BY name ASC")
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age); err != nil {
log.Fatal(err)
}
users = append(users, u)
}
rows.Close()
// 5. Update — raw SQL with placeholders
_, err = db.Exec(
"UPDATE user SET email = ?, age = ? WHERE id = ?",
"alice.new@example.com", 31, 1,
)
// 6. Delete — raw SQL
_, err = db.Exec("DELETE FROM user WHERE id = ?", 1)
That is ~115 lines of code for six basic operations. And every time you add a column, you must update the CREATE TABLE string, the INSERT columns, the SELECT columns, and the rows.Scan call. One typo in any of those, and you get a runtime error — no compile-time safety.
The Pain Points
| Pain | Why it hurts |
|---|---|
| Manual SQL writing | Every CRUD operation needs a raw SQL string — no compile-time check |
rows.Scan verbosity |
4–5 lines per query result just to map columns to struct fields |
| Transaction boilerplate |
db.Begin() + defer tx.Rollback() + tx.Commit() — repeated everywhere |
| No schema traceability | Table DDL lives in migration files, structs in Go — they drift |
| Error-prone column ordering | Adding a column means updating SQL strings and Scan calls |
Existing Solutions: sqlx, GORM, and the Missing Middle
The Go ecosystem offers two well-known paths to reduce this pain. Both have trade-offs.
sqlx: Better, But Still Manual
sqlx is a popular enhancement over database/sql. It adds StructScan, Get, Select, and named query parameters. You still write raw SQL, but rows.Scan is automated.
// sqlx: still manual SQL, but StructScan eliminates Scan
var u User
dbx.Get(&u, "SELECT id, name, email, age FROM user WHERE id = ?", 1)
sqlx saves about 30% of the boilerplate (down to ~80 lines). But you still write every CREATE TABLE, INSERT, SELECT, UPDATE, and DELETE by hand. SQL generation is not its job.
GORM: Full ORM, Full Magic
GORM is the heavyweight champion. It generates everything — schema, queries, migrations — and provides a rich chainable API. But it comes with a cost:
- Heavy reflection overhead at runtime
- Steep learning curve — tags, hooks, scopes, associations
- ~4 MB binary size increase just for the ORM
- Magic that hides complexity — until it doesn't, and you spend hours debugging
For large teams with dedicated DBAs and complex domain models, GORM is a solid choice. For CLI tools, startups, and small-to-medium services, it's overkill.
sqlh: The Sweet Spot
| Feature | database/sql |
sqlx | GORM | sqlh |
|---|---|---|---|---|
| SQL generation | ❌ Manual | ❌ Manual | ✅ Full | ✅ Full |
rows.Scan needed |
✅ Yes | ❌ StructScan
|
❌ Auto | ❌ Auto |
| Type-safe (generics) | ❌ | ❌ | ❌ | ✅ |
| Auto-transactions | ❌ | ❌ | ✅ | ✅ |
| Lock retry | ❌ | ❌ | ❌ | ✅ |
| Learning curve | Medium | Medium | High | Low |
| Binary size overhead | 0 | ~200 KB | ~4 MB | ~200 KB |
sqlh lives between sqlx and GORM:
- Zero-boilerplate CRUD — struct tags generate all SQL
-
Type-safe via Go generics —
Get[User]()returns*User, notinterface{} - No magic — what you see in the struct is what you get in the database
- Lightweight — minimal reflection, cached metadata, no hidden complexity
How sqlh Works: Struct Tags as Single Source of Truth
The core idea is simple: your Go struct is your schema.
type User struct {
ID int64 `db:"id" db_key:"not null primary key autoincrement"`
Name string `db:"name" db_key:"unique"`
Email string `db:"email"`
Age int `db:"age"`
}
Three struct tags control everything:
| Tag | Purpose | Example |
|---|---|---|
db |
Column name | db:"user_name" |
db_key |
Constraints, indexes | db_key:"primary key autoincrement" |
db_type |
SQL type override | db_type:"TEXT" |
From this single struct definition, sqlh generates:
-
CREATE TABLE —
sqlh.Create[User](db)generates and executesCREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, email TEXT, age INTEGER) -
INSERT —
sqlh.Insert(db, User{Name: "Alice"})generatesINSERT INTO user (name, email, age) VALUES (?, ?, ?) -
SELECT —
sqlh.Get[User](db, ...)generatesSELECT id, name, email, age FROM user WHERE ... LIMIT 2 -
UPDATE —
sqlh.Update(db, ...)generatesUPDATE user SET name=?, email=?, age=? WHERE ... -
DELETE —
sqlh.Delete[User](db, ...)generatesDELETE FROM user WHERE ...
Architecture
┌─────────────────────────────────────────────┐
│ sqlh package │
│ Insert, Get, List, Update, Delete, Set, │
│ Create — with auto-transactions │
├─────────────────────────────────────────────┤
│ query package │
│ SQL generation, metadata cache, JOINs │
├─────────────────────────────────────────────┤
│ database/sql (stdlib) │
│ Connection pool, raw query execution │
└─────────────────────────────────────────────┘
Key Design Decisions
-
Generics-first (Go 1.25+) —
Get[User]()returns*Userwith compile-time type safety. Nointerface{}, no type assertions. -
Reflection at call-time — Struct metadata is parsed once and cached in a
sync.Mapkeyed byreflect.Type. Subsequent calls reuse table names, field lists, and scan metadata. -
Auto-transactions on writes — Every
Insert,Update,Delete, andSetis automatically wrapped inBEGIN...COMMITwithROLLBACKon error. You never forget a transaction again. - Database lock retry — SQLite "database is locked" errors are automatically retried up to 20 times with 100ms backoff. Production-grade resilience out of the box.
- Multi-database support — SQLite (primary), MySQL, PostgreSQL (both CI-tested), and SQL Server (experimental).
CRUD in 50 Lines: The Quick Start
Here's the complete CRUD workflow with sqlh. Same operations as the raw SQL example above — ~57% less code:
package main
import (
"database/sql"
"fmt"
"github.com/kirill-scherba/sqlh"
_ "github.com/mattn/go-sqlite3"
)
type User struct {
ID int64 `db:"id" db_key:"not null primary key autoincrement"`
Name string `db:"name" db_key:"unique"`
Email string `db:"email"`
Age int `db:"age"`
}
func main() {
db, _ := sql.Open("sqlite3", "file::memory:?cache=shared")
defer db.Close()
// 1. Create table from struct
sqlh.Create[User](db)
// 2. Insert
sqlh.Insert(db, User{Name: "Alice", Email: "alice@example.com", Age: 30})
bobID, _ := sqlh.InsertId(db, User{Name: "Bob", Email: "bob@example.com", Age: 25})
// 3. Get by ID — returns *User, not interface{}
u, _ := sqlh.Get[User](db, sqlh.Eq("id", bobID))
fmt.Println(u.Name) // "Bob"
// 4. List all — returns []User + next offset
users, _, _ := sqlh.List[User](db, 0, "", "name ASC")
fmt.Println(len(users)) // 2
// 5. Update — pass full struct to avoid zeroing other columns
sqlh.Update(db, sqlh.UpdateAttr[User]{
Row: User{Name: "Alice", Email: "alice.new@example.com", Age: 31},
Wheres: []sqlh.Where{sqlh.Eq("id", 1)},
})
// 6. Delete
sqlh.Delete[User](db, sqlh.Eq("id", bobID))
}
~50 lines. No raw SQL. No rows.Scan. No transaction management. No column ordering errors.
Side-by-Side Comparison
| Operation | Raw database/sql
|
sqlx | sqlh |
|---|---|---|---|
| CREATE TABLE | Raw SQL string | Raw SQL string | sqlh.Create[User](db) |
| INSERT | Exec(?,?,?) |
NamedExec |
Insert(T) |
| GET | QueryRow + Scan |
Get(&T) |
Get[T](where) |
| LIST | rows.Next + Scan |
Select |
List[T](...) |
| UPDATE | Exec(?,?,?,?) |
NamedExec |
Update(attr) |
| DELETE | Exec(?) |
Exec(?) |
Delete[T](where) |
| COUNT | QueryRow + Scan |
Get(&int) |
Count[T]() |
| Lines of code | Reduction | |
|---|---|---|
Raw database/sql
|
~115 | baseline |
| sqlx | ~80 | −30% |
| sqlh | ~50 | −57% |
Benchmarks: The Numbers
How fast is sqlh in practice? I created a standalone bench/ module comparing raw database/sql, sqlx, GORM, and sqlh on the same CRUD workload. All benchmarks use in-memory SQLite — zero external setup.
Reproduce on your machine:
cd bench && go test -bench=. -benchmem -benchtime=1s
CRUD Throughput (ops/sec)
| Operation | raw sql | sqlx | GORM | sqlh |
|---|---|---|---|---|
| Insert | 158,856 | 131,337 | 35,288 | 85,631 |
| Get by PK | 169,090 | 150,082 | 77,489 | 73,601 |
| List all | 11,857 | 9,076 | 6,775 | 7,607 |
| List limit 10 | 51,000 | 43,381 | 37,666 | 44,204 |
| Update | 226,963 | 177,242 | 65,828 | 84,083 |
| Delete | 170,503 | 163,185 | 41,375 | 60,503 |
Memory Allocations (bytes/op, allocs/op)
| Operation | raw sql | sqlx | GORM | sqlh |
|---|---|---|---|---|
| Insert | 328 B, 12 | 721 B, 20 | 5,536 B, 82 | 1,274 B, 39 |
| Get by PK | 792 B, 27 | 976 B, 31 | 3,952 B, 66 | 2,593 B, 78 |
| List all | 23,744 B, 528 | 26,376 B, 632 | 27,669 B, 946 | 26,394 B, 745 |
| List limit | 3,120 B, 76 | 3,624 B, 91 | 6,145 B, 141 | 3,958 B, 115 |
| Update | 296 B, 10 | 680 B, 19 | 5,079 B, 68 | 1,393 B, 43 |
| Delete | 216 B, 7 | 216 B, 7 | 5,483 B, 67 | 1,139 B, 37 |
What the Numbers Tell Us
- GORM has the highest latency and allocation footprint across all operations, reflecting its rich feature set and internal reflection overhead.
- sqlh sits between raw sql/sqlx and GORM. The moderate overhead comes from auto-generated SQL, struct tag parsing, and built-in transaction wrapping for writes.
- sqlh trades raw speed for correctness: every write is auto-transacted with rollback on error, eliminating an entire class of bugs at the cost of ~2–6x latency versus raw sql for single-row mutations.
- ListAll is dominated by the cost of scanning 100 rows. All libraries show similar performance here.
Environment: Linux AMD Ryzen 9 3900, Go 1.26.3, SQLite in-memory.
Runcd bench && go test -bench=. -benchmem -benchtime=1son your own hardware for an apples-to-apples comparison.
When to Use sqlh
sqlh is not a silver bullet. Here's where it shines and where you might want something else:
| Use case | Recommendation |
|---|---|
| CLI tools & utilities | ✅ Perfect — zero migration files, single binary |
| Startups & MVPs | ✅ Ship faster, refactor later |
| Microservices with simple schemas | ✅ Low overhead, type-safe |
| High-throughput OLTP (>100K writes/sec) | ⚠️ Test first — raw sql may be needed |
| Complex multi-table analytics | ⚠️ Prefer raw SQL or a query builder |
| Large teams with dedicated DBAs | ⚠️ GORM or sqlx may fit better |
| Learning Go + SQL | ✅ Great teaching tool — low cognitive load |
Getting Started
go get github.com/kirill-scherba/sqlh
What’s Next
sqlh is actively developed. As of v0.8.0 (June 2026), the library supports:
- ✅ Full CRUD with auto-transactions
- ✅ Native UPSERT (PostgreSQL, SQLite, MySQL)
- ✅ JOIN queries with composite struct scanning
- ✅ Go 1.25 iterators (
ListRange) for lazy streaming - ✅ Type-safe WHERE helpers (
Eq,Ne,Gt,Like,In, etc.) - ✅ Database lock retry for SQLite
- ✅ Multi-database support (SQLite, MySQL, PostgreSQL)
On the roadmap: aggregate functions (SUM, AVG), schema migrations, batch operations, and transactional reads. The API is stabilizing toward v1.0.0.
If you’re building a Go project that talks to SQL and you’re tired of writing the same boilerplate over and over — give sqlh a try. Define your struct. That’s it.
Written by Kirill Scherba. sqlh is open source under the BSD license. Contributions welcome.
Top comments (0)