xsql is a small, stdlib-style layer over database/sql that eliminates
repetitive row-mapping code without hiding SQL behind an ORM. You keep full
control of your queries while getting type-safe scanning into structs,
primitives, and custom types that implement sql.Scanner.
It’s designed for developers who value clarity, simplicity, and performance,whether you’re working on a large production system or just learning Go’s database API.
xsql is designed for Go developers who want to keep the simplicity of
database/sql while eliminating repetitive boilerplate. It integrates
seamlessly with your existing code without forcing an ORM or complex
abstractions.
Key capabilities include:
- Strongly typed query functions that map directly into structs, primitives, or custom scanner types without reflection-heavy frameworks.
- Zero configuration required; works with
*sql.DB,*sql.Tx, and*sql.Conn. - Automatic column-to-field mapping with
dbtags, falling back to case-insensitive field names. - Safe handling of empty results for single-row queries, returning
nilinstead of panics. - Built-in plan caching for performance, initialized lazily and safe for concurrent access.
- Fully compatible with native SQL syntax, no query builders or DSLs.
- Small, focused API:
Query,Get, andExeccover the majority of use cases.
The standard database/sql package is intentionally low-level - it gives you
complete control, but it also means you spend a lot of time writing the same
patterns over and over: creating rows, looping, scanning values into
variables, handling conversion errors, and appending to slices.
That’s great for ultimate flexibility, but not great for everyday productivity. Here’s the typical dance:
rows, err := db.QueryContext(ctx, "SELECT id, email FROM users WHERE active = ?", 1)
if err != nil {
return err
}
defer rows.Close()
var results []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Email); err != nil {
return err
}
results = append(results, u)
}
if err := rows.Err(); err != nil {
return err
}With xsql, the same thing becomes:
users, err := xsql.Query[User](ctx, db, "SELECT id, email FROM users WHERE active = ?", 1)That’s it. You still decide the SQL. You still decide how to structure your queries and joins. xsql simply takes care of the mechanical parts - scanning, type conversion, and slice building - while staying out of your way.
xsql is not an ORM, it doesn’t hide SQL, and it doesn’t try to reinvent how you talk to databases. Instead, it gives you minimal, type-safe helpers so you can:
- Write SQL the way you like, with full control over queries and schema.
- Map results into your Go types automatically without losing performance.
- Reduce repetitive scanning code and keep your functions concise.
- Maintain compatibility with all database drivers supported by
database/sql. - Keep learning curves low for new developers while giving experts the control they expect.
The result is code that looks clean, compiles with type safety, and performs just as well as hand-written scanning- while remaining transparent and debuggable.
xsql works with Go 1.20+ and any modern SQL driver that implements the
database/sql/driver interface. To install xsql, simply run:
go get github.com/go-mizu/xsqlYou will also need a driver for your database. For example, you can use:
go get github.com/jackc/pgx/v5 # PostgreSQL
go get github.com/go-sql-driver/mysql # MySQL
go get modernc.org/sqlite # SQLite (CGO-free)In your code, import xsql alongside your chosen driver:
import (
"context"
"database/sql"
"log"
"github.com/go-mizu/xsql"
_ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
)The following is a minimal example showing how to use xsql to query data into a struct.
type User struct {
ID int64 `db:"id"`
Email string `db:"email"`
}
func main() {
db, err := sql.Open("pgx", "postgres://user:pass@localhost/dbname")
if err != nil {
log.Fatal(err)
}
defer db.Close()
db.Exec(`CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT, active BOOLEAN)`)
db.Exec(`INSERT INTO users (email, active) VALUES ('[email protected]', true), ('[email protected]', false)`)
ctx := context.Background()
users, err := xsql.Query[User](ctx, db, `SELECT id, email FROM users WHERE active = $1`, true)
if err != nil {
log.Fatal(err)
}
for _, u := range users {
log.Println(u.ID, u.Email)
}
}You can also query directly into primitive slices when only one column is needed:
ids, err := xsql.Query[int64](ctx, db, `SELECT id FROM users WHERE active = $1`, true)When you need just one record, Get returns a single value instead of a slice:
u, err := xsql.Get[User](ctx, db, `SELECT id, email FROM users WHERE id = $1`, 1)
if err != nil {
log.Fatal(err)
}
if u != nil {
log.Println(u.ID, u.Email)
}xsql keeps the surface small so you can learn it in minutes. There are three core functions:
Query[T any](ctx context.Context, db DB, query string, args ...any) ([]T, error)
Runs a SQL query and maps all rows into a slice of type T. T can be a
struct, a primitive type, or a type implementing sql.Scanner. The db
argument can be a *sql.DB, *sql.Tx, or anything that implements
QueryContext.
type Product struct {
ID int64 `db:"id"`
Name string `db:"name"`
Price float64 `db:"price"`
}
ctx := context.Background()
products, err := xsql.Query[Product](ctx, db,
`SELECT id, name, price FROM products WHERE price > ?`, 10.0)
if err != nil {
log.Fatal(err)
}
fmt.Println(products)Get[T any](ctx context.Context, db DB, query string, args ...any) (T, error)
Runs a SQL query and returns the first row mapped to type T. If no rows are
found, it returns sql.ErrNoRows. Perfect for single-value lookups or when you
expect at most one row.
ctx := context.Background()
price, err := xsql.Get[float64](ctx, db,
`SELECT price FROM products WHERE id = ?`, 42)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
fmt.Println("No product found")
} else {
log.Fatal(err)
}
}
fmt.Println("Price:", price)Exec(ctx context.Context, db DB, query string, args ...any) (sql.Result, error)
Executes a SQL statement without returning rows, such as INSERT, UPDATE, or
DELETE. The db argument can be a *sql.DB, *sql.Tx, or anything that
implements ExecContext.
ctx := context.Background()
res, err := xsql.Exec(ctx, db,
`UPDATE products SET price = price - 1.1 WHERE category_id = ?`, 5)
if err != nil {
log.Fatal(err)
}
affected, _ := res.RowsAffected()
fmt.Println("Updated rows:", affected)Any type implementing sql.Scanner can be directly used with xsql. This is
useful for enums, JSON fields, or other domain-specific types.
type Email string
func (e *Email) Scan(src any) error {
switch v := src.(type) {
case []byte:
*e = Email(string(v))
case string:
*e = Email(v)
default:
return fmt.Errorf("unexpected type %T", src)
}
return nil
}
ctx := context.Background()
emails, err := xsql.Query[Email](ctx, db, `SELECT email FROM users`)
if err != nil {
log.Fatal(err)
}
fmt.Println(emails)xsql supports flattening nested structs when tagged with db:",inline". This
makes it easy to combine related data into one Go value without manual joins in
your code.
type Address struct {
City string `db:"city"`
Street string `db:"street"`
}
type Customer struct {
ID int64 `db:"id"`
Name string `db:"name"`
Address `db:",inline"`
}
ctx := context.Background()
customers, err := xsql.Query[Customer](ctx, db,
`SELECT id, name, city, street FROM customers`)
if err != nil {
log.Fatal(err)
}
fmt.Println(customers)xsql works seamlessly with transactions. Just pass a *sql.Tx in place of
*sql.DB for any function.
ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
log.Fatal(err)
}
defer tx.Rollback()
_, err := xsql.Exec(ctx, tx, `INSERT INTO logs(message) VALUES (?)`, "started")
if err != nil {
log.Fatal(err)
}
if err := tx.Commit(); err != nil {
log.Fatal(err)
}xsql is not an ORM. It is a thin, type-safe wrapper around the database/sql
standard library, designed to make mapping query results into Go types
predictable and fast. Instead of generating code or maintaining complex
metadata, xsql uses reflection only once per unique type/column set and stores
the mapping in a cache. This keeps the runtime cost low without sacrificing
developer experience.
The library avoids hidden behaviors. Every query you run maps directly to the SQL you write, so you remain in control of indexing, joins, and performance. This approach encourages developers to think about database performance from the start, while still enjoying concise and maintainable Go code.
We believe Go developers should not have to choose between bare database/sql
and heavy ORMs. xsql sits in the middle - minimal abstraction, but with enough
type awareness to eliminate repetitive boilerplate code.
When you call Query or Get with a type parameter T, xsql checks if a
column mapping for T already exists in its internal cache. If not, it inspects
the type using reflection, looks for db struct tags, and matches the columns
returned by the query to fields. Once computed, the mapping is stored in a
concurrency-safe cache keyed by the type and column list.
For primitive types or types implementing sql.Scanner, no field mapping is
required - values are scanned directly into the destination slice or variable.
- Prepare mapping: For structs, find the field index for each column. For primitives, skip mapping entirely.
- Run query: Execute using the
QueryContextorExecContextmethod of the provideddb. - Scan rows: Use the mapping to scan each row directly into the appropriate fields or variables.
- Return typed result: For
Query, return a slice ofT; forGet, return a singleT.
The first call for a given type/column set involves reflection and map allocation. Subsequent calls reuse the computed mapping. This approach yields ORM-like convenience without ORM-level overhead.
Custom sql.Scanner implementations receive raw database values, allowing
domain types to handle parsing themselves ( e.g., JSON fields, enum types, or
time formats).
We welcome contributions from both seasoned Go developers and those just starting out. The goal of xsql is to remain minimal and type-safe while fitting naturally into the Go ecosystem. If you have an idea, improvement, or bug fix, here’s how you can help:
- Fork the repository and create a feature branch.
- Write clear, focused commits with descriptive messages.
- Add or update tests to cover your changes.
- Run the full test suite with
go test ./...before submitting. - Open a pull request with a description of the changes and reasoning.
If you’re unsure about an idea, feel free to open an issue first to discuss it. We’re happy to give feedback before you start coding.
The test suite includes example-based documentation tests. This means many examples from the README and doc comments are also run during testing, ensuring that documentation and implementation stay in sync.
To run tests locally:
go test ./...Some tests use SQLite in-memory mode (modernc.org/sqlite) for simplicity. No
additional database setup is required to run them.
The repository is organized to keep the codebase simple and easy to navigate:
xsql/
├── doc.go - package overview and documentation
├── xsql.go - core interfaces and shared definitions
├── mapper.go - reflection-based mapping and caching
├── query.go - typed query helpers
├── get.go - single-row query helpers
├── exec.go - statement execution helper
├── *_test.go - example and unit tests
The package is designed for direct import without requiring code generation or additional tooling.
xsql is released under the MIT License. This means you can freely use, modify, and distribute the library in your own projects, whether commercial or open source, as long as the license terms are included.