github.com/jmoiron/sqlx is a thin wrapper around the standard database/sql package that adds two things people end up rewriting by hand: scanning rows directly into structs and using named parameters in queries. It's not an ORM, it doesn't manage migrations, and it doesn't hide SQL. sqlx.DB embeds *sql.DB, so every method you've learned in this section still works, plus a few new ones that cut the boilerplate around reads and writes.
The examples in this chapter need two third-party packages. In a real project you'd run go get github.com/jmoiron/sqlx and go get modernc.org/sqlite once, then import them like any standard library package. The schema is the same products and customers tables we've used throughout this section.
database/sql gives you four building blocks: Query, QueryRow, Exec, and Prepare. They all work, but every read ends with the same dance: declare variables, call rows.Scan(&a, &b, &c), remember rows.Close(), check rows.Err(). For a struct with ten fields that's ten &p.Field arguments to keep in sync with the SELECT list. sqlx removes that ceremony by reading column names from the *sql.Rows metadata and matching them to struct fields using a tag.
Here is the mapping between the two APIs side by side:
database/sql | sqlx equivalent | What changes |
|---|---|---|
db.Query(q, args...) | db.Queryx(q, args...) | Returns *sqlx.Rows with StructScan |
db.QueryRow(q, args...) | db.Get(&dst, q, args...) | Scans the one row directly into a struct |
db.Query(q, args...) + loop | db.Select(&dst, q, args...) | Scans every row into a slice of structs |
db.Exec(q, args...) | db.NamedExec(q, structOrMap) | Named :foo placeholders pulled from struct fields |
db.QueryRow(...).Scan(...) | db.NamedQuery(q, structOrMap) | Same, but for SELECTs |
sql.Open(driver, dsn) | sqlx.Open(driver, dsn) / sqlx.Connect(driver, dsn) | Connect also pings the database |
Everything else, transactions, contexts, prepared statements, connection pooling, behaves the same as plain database/sql because the underlying *sql.DB is the same object.
The first program just opens a database and confirms sqlx.DB is what you'd expect:
sqlx.Connect combines sqlx.Open with a Ping, so if the DSN is wrong or the driver can't reach the database, you find out immediately instead of on the first query. sqlx.Open mirrors sql.Open exactly: it validates the DSN but doesn't actually connect.
db.Get(&version, "SELECT sqlite_version()") shows the smallest possible read. Get runs the query, fetches one row, and writes the single column into the destination variable. The destination doesn't have to be a struct, a *string works fine for a single-column result.
Every sqlx read needs to know which column goes into which struct field. The library uses the db struct tag for this. Without tags, sqlx falls back to a lowercased version of the field name, which works often enough until a column is named created_at and the matching field is CreatedAt.
Tags are explicit and survive refactors. If you rename Price to UnitPrice in Go code, the db:"price" tag keeps the SQL mapping intact, so the database column doesn't have to change.
Here's a full program that creates the table, inserts a row, and reads it back with db.Get:
db.Exec is the same Exec you already know from database/sql. sqlx doesn't change its behavior; it inherits it. The new method is db.Get, which takes a pointer to a struct, runs the query, and scans the single result row into the struct using the db tags. If no row matches, Get returns sql.ErrNoRows, just like QueryRow().Scan() does.
Cost: sqlx uses reflection once per struct type to build a field-to-column index, then caches it. The first call against a new type pays a small reflection cost; subsequent calls are close to hand-written Scan performance. For hot paths with millions of rows you might still profile, but for typical web-app workloads the difference is in the noise.
Get and Select are the two methods used most often. They're the whole reason most people adopt sqlx. Get handles one row, Select handles many.
A few details:
db.MustExec is a convenience that panics if Exec fails. Use it in tests, schema setup, and tiny scripts; in real handler code stick with Exec and check the error.Select expects a pointer to a slice. It allocates the slice for you and appends each row. If the query returns zero rows, you get an empty slice and a nil error, not sql.ErrNoRows.Get returns sql.ErrNoRows when nothing matches, the same sentinel QueryRow().Scan() returns. The error model is identical, so errors.Is(err, sql.ErrNoRows) still works.Compare the raw scan loop against the Select one-liner:
The raw path has five steps and at least two defer statements (rows.Close and the error checks). The sqlx path collapses it into one call. The trade-off is that you give up control over the iteration: Select loads the entire result set into memory before returning. For a query that returns 50 rows that's nothing; for a query that returns 5 million rows that's a problem.
For those big-result cases, drop down to Queryx + StructScan and keep the loop:
Queryx is the sqlx version of Query and returns *sqlx.Rows instead of *sql.Rows. The only new method on *sqlx.Rows is StructScan, which fills a struct in place using the db tags. Everything else (Next, Close, Err) is the standard sql.Rows API you already know.
Cost: Select materializes the full result set. For 1,000 rows of Product that's roughly 50 KB. For 1 million rows it's 50 MB held in memory at once, plus a transient bump while the slice grows. If the row count is unbounded, prefer the Queryx + StructScan loop so you can process and discard each row.
The other big convenience sqlx adds is named placeholders. Instead of ? (or $1, $2 for Postgres), you write :name, and sqlx pulls the matching value out of a struct or map[string]any. This eliminates the positional-argument bugs that creep in when you reorder columns in an INSERT and forget to reorder the matching args slice.
NamedExec takes a query containing :fieldname placeholders and a struct (or map). It looks up each named placeholder by db tag on the struct, builds a positional query internally, and runs it. The return value is the standard sql.Result, so LastInsertId and RowsAffected work as usual.
NamedQuery is the SELECT version. It doesn't return a slice the way Select does; it returns a cursor (*sqlx.Rows) and you call StructScan in a loop. The library doesn't ship a NamedSelect, so when you need both named parameters and a slice destination, you call NamedQuery and iterate.
A common pattern is updates that touch many columns:
The same Product value drives both the SET clause and the WHERE clause. Compare that with the positional version:
It's not dramatically shorter, but the named form survives column reordering without bugs. Swap name and price in the SQL and the named query keeps working; the positional one silently writes the wrong value into the wrong column.
You can also pass a map[string]any instead of a struct when you don't have a matching type:
Maps are useful for partial updates and one-off queries; structs are better when the same shape gets used across many calls.
SQL's IN clause takes a fixed number of values, not a slice. If you want to find all products whose IDs are in a []int64, you can't write WHERE id IN (?) and pass the slice; you'd get a syntax error or a single bound parameter holding the whole slice. The standard database/sql answer is to build the ?, ?, ? list yourself with strings.Repeat or a loop. sqlx.In does that for you.
sqlx.In takes a query with a single ? placeholder representing the IN list and a slice. It expands the placeholder into the right number of ? marks and returns the rewritten query plus a flat []any of arguments ready to pass to Select, Get, or Exec. Calling it with ids = []int64{1, 3, 4} rewrites the query to ... WHERE id IN (?, ?, ?) and returns args = []any{1, 3, 4}.
The db.Rebind call exists because sqlx.In always produces ? placeholders, but Postgres uses $1, $2, etc., and Oracle uses :1. Rebind converts to whatever style your driver expects. For SQLite and MySQL it's a no-op; for Postgres it does real work. Calling it unconditionally is the safe habit, so swapping drivers later doesn't break the query.
You can mix IN-expansion with other parameters too, as long as the IN slice's ? is the first argument and the rest follow:
Here args becomes []any{1, 3, 4, 50.0} and the query has four ? placeholders. The slice expands in place; everything else stays positional.
Cost: Every distinct slice length produces a distinct SQL string, which means a distinct query-plan cache entry on the database side. For Postgres and MySQL with prepared-statement caches, a thousand different IN lengths can blow out the cache. If you're hitting that, normalize the lengths (always pad to powers of 2) or use a temp table.
Transactions work the same way. The only difference is the method name: db.Beginx() instead of db.Begin(). It returns a *sqlx.Tx that has all the standard *sql.Tx methods (Commit, Rollback, Exec, Query, Prepare) plus the new ones (Get, Select, NamedExec, NamedQuery). You don't need to learn two transaction APIs; you keep the patterns you already know and write less code inside them.
tx.Get and tx.NamedExec work exactly like db.Get and db.NamedExec, just bound to the transaction. The defer tx.Rollback() is the standard idiom: after tx.Commit() succeeds, calling Rollback is a safe no-op that returns sql.ErrTxDone, which the deferred call ignores. If anything before Commit returns early, the rollback runs and undoes whatever the transaction had touched.
sqlx doesn't change the transaction semantics, isolation levels, or context behavior. It just lets you keep using struct scanning inside the transaction.
The Go ecosystem has roughly three tiers for talking to a relational database. Each one trades a different amount of control for a different amount of convenience. The choice is rarely about performance, it's about how much SQL your team wants to write by hand and how much "magic" you're comfortable with.
| Aspect | Raw database/sql | sqlx | Full ORM (GORM, ent) |
|---|---|---|---|
| You write SQL | Yes | Yes | Sometimes; the ORM generates most of it |
| Struct scanning | Manual rows.Scan(&a, &b) | Automatic via db tags | Automatic |
| Migrations | External tool | External tool | Built in (varies) |
| Relationship loading | Manual joins | Manual joins | Built in (joins, preload) |
| Boilerplate | High | Low | Lowest |
| Surprise factor | Lowest | Low | Medium to high |
| Reflection cost | None | Per-type, cached | Per-call, varies |
| Dependencies | Just the driver | jmoiron/sqlx + driver | Large dependency tree |
| Learning curve | Small, well documented | Small, just a thin layer | Medium to large |
| Good fit for | Strict performance budgets, libraries | App services that own their schema | Rapid CRUD, generated APIs |
database/sql alone is the right choice when you're writing a library other people depend on (no extra dependency is welcome), when you need every ounce of control over how rows are read, or when your queries are simple enough that struct scanning doesn't save much. The same applies when your team is small and an extra dependency isn't worth a 30-line saving.
sqlx fits the middle ground where most application code lives. You're still writing SQL by hand, you still know exactly what query runs against the database, but you don't waste lines on &p.Field scan arguments and you get named parameters for free. The library is small, well documented, and stable; the dependency is roughly as risky as the database driver itself.
A full ORM like GORM or ent is the right choice when you want generated CRUD, automatic migrations, relationship eager-loading, and you accept that the SQL you see in logs might not be the SQL you'd have written. ORMs trade transparency for productivity. The trade is fine until you hit a query plan problem and have to reverse-engineer the generated SQL, which is when many teams either learn the ORM's escape hatches or migrate part of the codebase back to raw queries.
Start with database/sql for the simplest case, move to sqlx when you find yourself writing the same scan boilerplate three times in a row, and only adopt a full ORM if you're confident the productivity gains outweigh the loss of control. A single project can mix tiers: sqlx for normal CRUD, raw database/sql for that one hot-path query you've tuned by hand, and no ORM if the schema is small enough.
Cost: Mixing tools in the same project isn't free either. Two query styles in one codebase means two sets of conventions, two ways tests get written, and two places someone has to remember which connection pool they're using. Pick one default and break from it only with a reason.
One small follow-up: sqlx exports sqlx.NullString, sqlx.NullInt64, sqlx.NullBool, sqlx.NullFloat64, and sqlx.NullTime as aliases for the matching sql.Null* types. They behave identically, so the null-handling patterns carry over unchanged. The aliases exist so you don't need to import database/sql separately when you're already using sqlx. They aren't a different feature.
10 quizzes