database/sql
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27.
The database/sql package is Go’s single, driver-agnostic API for relational databases: a thread-safe handle that owns
a pool of connections, a small set of types for statements, rows and transactions, and an interface that third-party
drivers implement for PostgreSQL, MySQL, SQLite, SQL Server, Oracle and the rest. The standard library ships no driver
at all, so a Go program always combines database/sql with a driver module. This page covers the whole package, how
to use it safely, how it behaves under cancellation and load, and how it is structured inside so that you can read a
driver or write a small one.
It builds on Context (every call below has a Context variant) and
Error Handling (errors.Is, errors.As, wrapping). Related pages
are Interfaces (Scanner and Valuer are interfaces you implement),
Generics (sql.Null[T]),
Iterators and Range-over-Func,
net/http Clients and Servers,
encoding/json,
Structured Logging with slog,
Testing and
Crypto and Security. The SQL language itself, schema design and
the behaviour of transactions inside the server are not repeated here: the
SQL Reference on this site covers them, and the other data stores are linked from
Other data stores from Go.
How the examples were verified
There is no database server on the machine that wrote this page, and the rules of the section forbid third-party
modules, so the examples run against a tiny in-memory driver written for the purpose (A tiny in-memory driver for the examples). Everything the
examples show about database/sql itself is real: the pool statistics, the error messages that come from the package
(sql: expected 4 destination arguments in Scan, not 3), the way contexts and transactions interact, which methods the
driver sees and in what order, and the Go 1.27 behaviour. Everything that depends on a real server (query planning,
isolation anomalies, which placeholder syntax or error codes your database uses, LastInsertId support) is described
from the documentation and labelled as such. Statements that need a real server are shown as fragments marked "not
run".
Every complete program was compiled, vetted with go vet, formatted with gofmt and run with Go 1.27.2; the output
shown is the real output. The example module is:
module example.com/shelf
go 1.27
The big picture
database/sql is deliberately thin. It does not parse SQL, map rows to structs or generate queries. It provides:
-
a handle,
*sql.DB, that represents a database (not a connection) and manages a pool of connections for you; -
the calls that send SQL (
Exec,Query,QueryRow,Prepare,Begin) and the types that carry the results (Result,*Rows,*Row,*Stmt,*Tx,*Conn); -
conversion between database values and Go values (
Scan,Scanner,driver.Valuer,sql.Null[T]); -
retry, pooling, cancellation and bookkeeping that every driver would otherwise repeat.
The driver (a module you import) implements the small interfaces in database/sql/driver and speaks the wire protocol
of one database. Because only the interfaces are shared, your code stays portable across drivers, though not across
SQL dialects: placeholder syntax, types and error codes still belong to the database.
The same shape appears in other ecosystems (JDBC, ODBC, Python’s DB-API), but two Go-specific decisions matter in
practice. First, *sql.DB is meant to be created once and shared by the whole program, like an http.Client;
creating one per request defeats pooling. Second, there is no ORM in the standard library: libraries such as sqlx
(struct scanning helpers), sqlc (type-safe Go generated from SQL files), ent or GORM are built on top of
database/sql or on drivers directly, and are a project decision rather than part of the language.
The official documentation is organised as a series of short guides under Accessing relational databases, plus a tutorial that builds a small data-access layer against MySQL. This page follows the same topics (opening a handle, querying, changing data, prepared statements, transactions, cancellation, connection management, SQL injection) and adds the pieces the guides leave out: the package’s error vocabulary, the pool behaviour you can observe, writing a driver and the tools that check your code.
A tiny in-memory driver for the examples
A driver is a handful of types. The listing below is the complete driver used by every example on this page. It keeps a
books table in memory, understands about a dozen exact statements (it is not a SQL parser) and records what it
receives, so the examples can show what actually reaches the driver. It deliberately implements the optional
QueryerContext and ExecerContext fast paths (like real drivers) and a separate Prepare path (so that prepared
statements are observable), and a FailRowsAfter switch that makes row iteration fail half-way.
internal/fakedb/fakedb.go (click to expand)
// Package fakedb is a tiny in-memory database/sql driver used by the
// examples. It understands a handful of exact statements about a books table.
package fakedb
import (
"context"
"database/sql/driver"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
)
// Error is the driver-specific error type returned for constraint failures.
type Error struct{ Code, Message string }
func (e *Error) Error() string { return "fakedb: " + e.Message + " (" + e.Code + ")" }
type book struct {
id int64
isbn string
title string
year any // int64 or nil (NULL)
copies int64
}
// DB is both a driver.Connector and a driver.Driver over shared in-memory data.
type DB struct {
mu sync.Mutex
books []book
nextID int64
log []string
connects int
FailRowsAfter int // when > 0, row iteration fails after this many rows
}
// New returns a database holding three books.
func New() *DB {
return &DB{nextID: 4, books: []book{
{1, "9780134190440", "The Go Programming Language", int64(2015), 3},
{2, "9781492077213", "Learning Go", int64(2021), 2},
{3, "9781617291784", "Go in Action", nil, 1},
}}
}
// Log returns the statements the driver has received, oldest first.
func (d *DB) Log() []string {
d.mu.Lock()
defer d.mu.Unlock()
return append([]string(nil), d.log...)
}
// ResetLog clears the log.
func (d *DB) ResetLog() { d.mu.Lock(); d.log = nil; d.mu.Unlock() }
// Connects reports how many physical connections were opened.
func (d *DB) Connects() int { d.mu.Lock(); defer d.mu.Unlock(); return d.connects }
func (d *DB) logf(format string, a ...any) {
d.mu.Lock()
d.log = append(d.log, fmt.Sprintf(format, a...))
d.mu.Unlock()
}
// Open implements driver.Driver; the DSN is ignored.
func (d *DB) Open(dsn string) (driver.Conn, error) { return d.Connect(context.Background()) }
// Driver implements driver.Connector.
func (d *DB) Driver() driver.Driver { return d }
// Connect implements driver.Connector.
func (d *DB) Connect(ctx context.Context) (driver.Conn, error) {
d.mu.Lock()
d.connects++
d.mu.Unlock()
return &conn{db: d}, nil
}
type conn struct {
db *DB
readOnly bool
undo []book // snapshot taken by BeginTx
inTx bool
}
func (c *conn) Prepare(q string) (driver.Stmt, error) {
c.db.logf("prepare %s", norm(q))
return &stmt{c: c, q: q}, nil
}
func (c *conn) Close() error { return nil }
func (c *conn) Begin() (driver.Tx, error) {
return c.BeginTx(context.Background(), driver.TxOptions{})
}
func (c *conn) Ping(ctx context.Context) error { return ctx.Err() }
func (c *conn) BeginTx(ctx context.Context, o driver.TxOptions) (driver.Tx, error) {
if lvl := sqlLevel(o.Isolation); lvl != "" {
return nil, fmt.Errorf("fakedb: isolation level %s not supported", lvl)
}
c.db.mu.Lock()
c.undo = append([]book(nil), c.db.books...)
c.db.mu.Unlock()
c.readOnly, c.inTx = o.ReadOnly, true
c.db.logf("begin readonly=%v", o.ReadOnly)
return c, nil
}
func sqlLevel(l driver.IsolationLevel) string {
switch l {
case 0, 6: // default, serializable
return ""
}
return fmt.Sprintf("%d", l)
}
func (c *conn) Commit() error {
c.db.logf("commit")
c.undo, c.readOnly, c.inTx = nil, false, false
return nil
}
func (c *conn) Rollback() error {
c.db.logf("rollback")
c.db.mu.Lock()
c.db.books = c.undo
c.db.mu.Unlock()
c.undo, c.readOnly, c.inTx = nil, false, false
return nil
}
func (c *conn) QueryContext(ctx context.Context, q string, args []driver.NamedValue) (driver.Rows, error) {
return c.query(ctx, q, args)
}
func (c *conn) ExecContext(ctx context.Context, q string, args []driver.NamedValue) (driver.Result, error) {
return c.exec(q, args)
}
type stmt struct {
c *conn
q string
}
func (s *stmt) Close() error { return nil }
func (s *stmt) NumInput() int { return strings.Count(s.q, "?") }
func (s *stmt) Exec(args []driver.Value) (driver.Result, error) {
return nil, errors.New("fakedb: use ExecContext")
}
func (s *stmt) Query(args []driver.Value) (driver.Rows, error) {
return nil, errors.New("fakedb: use QueryContext")
}
func (s *stmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) {
return s.c.exec(s.q, args)
}
func (s *stmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) {
return s.c.query(ctx, s.q, args)
}
func norm(q string) string { return strings.Join(strings.Fields(q), " ") }
func logArgs(args []driver.NamedValue) string {
parts := make([]string, len(args))
for i, a := range args {
if a.Name != "" {
parts[i] = fmt.Sprintf("%s=%#v", a.Name, a.Value)
} else {
parts[i] = fmt.Sprintf("%#v", a.Value)
}
}
return "[" + strings.Join(parts, " ") + "]"
}
func (c *conn) check(q string, args []driver.NamedValue) error {
if n := strings.Count(q, "?"); n != len(args) {
return fmt.Errorf("fakedb: query has %d placeholders but got %d arguments", n, len(args))
}
return nil
}
func (c *conn) query(ctx context.Context, q string, args []driver.NamedValue) (driver.Rows, error) {
q = norm(q)
c.db.logf("query %s %s", q, logArgs(args))
if err := c.check(q, args); err != nil {
return nil, err
}
d := c.db
d.mu.Lock()
defer d.mu.Unlock()
const sel = "SELECT id, isbn, title, year FROM books"
cols := []string{"id", "isbn", "title", "year"}
var out [][]driver.Value
row := func(b book) []driver.Value { return []driver.Value{b.id, b.isbn, b.title, b.year} }
switch q {
case sel + " WHERE isbn = ?":
for _, b := range d.books {
if b.isbn == args[0].Value {
out = append(out, row(b))
}
}
case sel + " ORDER BY id":
for _, b := range d.books {
out = append(out, row(b))
}
case sel + " WHERE title LIKE ? ORDER BY id":
pat := strings.ToLower(strings.Trim(fmt.Sprint(args[0].Value), "%"))
for _, b := range d.books {
if strings.Contains(strings.ToLower(b.title), pat) {
out = append(out, row(b))
}
}
case "SELECT count(*) FROM books":
cols, out = []string{"count"}, [][]driver.Value{{int64(len(d.books))}}
case "SELECT copies FROM books WHERE isbn = ?":
cols = []string{"copies"}
for _, b := range d.books {
if b.isbn == args[0].Value {
out = append(out, []driver.Value{b.copies})
}
}
case "SELECT slow()":
d.mu.Unlock()
select {
case <-ctx.Done():
d.mu.Lock()
return nil, ctx.Err()
case <-time.After(10 * time.Second):
}
d.mu.Lock()
cols, out = []string{"slow"}, [][]driver.Value{{int64(1)}}
default:
return nil, fmt.Errorf("fakedb: unsupported statement %q", q)
}
return &rows{cols: cols, data: out, failAfter: d.FailRowsAfter, pos: -1}, nil
}
type result struct{ id, n int64 }
func (r result) LastInsertId() (int64, error) { return r.id, nil }
func (r result) RowsAffected() (int64, error) { return r.n, nil }
func (c *conn) exec(q string, args []driver.NamedValue) (driver.Result, error) {
q = norm(q)
c.db.logf("exec %s %s", q, logArgs(args))
if err := c.check(q, args); err != nil {
return nil, err
}
if c.readOnly {
return nil, &Error{"25006", "cannot execute in a read-only transaction"}
}
d := c.db
d.mu.Lock()
defer d.mu.Unlock()
switch q {
case "INSERT INTO books (isbn, title, year) VALUES (?, ?, ?)":
for _, b := range d.books {
if b.isbn == args[0].Value {
return nil, &Error{"23505", "duplicate isbn " + fmt.Sprint(args[0].Value)}
}
}
b := book{id: d.nextID, isbn: args[0].Value.(string), title: args[1].Value.(string), year: args[2].Value, copies: 1}
d.nextID++
d.books = append(d.books, b)
return result{b.id, 1}, nil
case "UPDATE books SET copies = copies + ? WHERE isbn = ?":
var n int64
for i := range d.books {
if d.books[i].isbn == args[1].Value {
d.books[i].copies += args[0].Value.(int64)
n++
}
}
return result{0, n}, nil
case "DELETE FROM books WHERE isbn = ?":
var n int64
kept := d.books[:0:0]
for _, b := range d.books {
if b.isbn == args[0].Value {
n++
continue
}
kept = append(kept, b)
}
d.books = kept
return result{0, n}, nil
}
return nil, fmt.Errorf("fakedb: unsupported statement %q", q)
}
type rows struct {
cols []string
data [][]driver.Value
pos int
failAfter int
}
func (r *rows) Columns() []string { return r.cols }
func (r *rows) Close() error { return nil }
func (r *rows) Next(dest []driver.Value) error {
r.pos++
if r.failAfter > 0 && r.pos >= r.failAfter {
return errors.New("fakedb: connection reset by peer")
}
if r.pos >= len(r.data) {
return io.EOF
}
copy(dest, r.data[r.pos])
return nil
}
Source: database/sql/driver
Things this driver does not model, and a real database does: SQL parsing and planning, concurrency control (a rollback here restores a snapshot, whatever other connections did meanwhile), type systems, network failure other than the injected one, and prepared statements living on the server. Treat the examples as demonstrations of the Go API, not of database behaviour.
Source: database/sql/driver
Drivers and opening a handle
Choosing and importing a driver
Drivers are ordinary modules. Each registers itself under a name in an init function, so a program imports it only for
that side effect with the blank identifier (see Packages, Imports and Initialization):
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // registers the name "pgx"
)
Source: Opening a database handle
This fragment is not run (it needs a third-party module and a server). The Go wiki keeps a list of drivers and the documentation describes how to locate and import one. The registered name, the DSN (data source name) syntax and the placeholder syntax are all driver-specific; read the driver’s documentation rather than guessing.
| Database | Placeholder style (typical) | Notes |
|---|---|---|
PostgreSQL |
|
Ordinal; the same |
MySQL, MariaDB, SQLite (most drivers) |
|
Positional only; repeat the value if it is used twice. |
SQL Server |
|
Supports |
Oracle |
|
Supports |
The table is a guide to the common drivers, not a guarantee; the driver you import decides.
sql.Open, sql.OpenDB and Ping
sql.Open(driverName, dsn) returns a *sql.DB. It validates its arguments and usually does not connect: the first
real connection is made when it is needed, and a wrong host or password is first reported by the first query. To fail
fast at start-up, call PingContext with a timeout. A misspelled or forgotten driver is reported immediately:
sql: unknown driver "postgres" (forgotten import?)
(That is the real message from sql.Open("postgres", "dsn") in a program that registered no such driver.)
sql.OpenDB(connector) takes a driver.Connector instead of a name. It skips the global registry, which is useful when
a driver builds its connector from a typed configuration struct (no DSN string to parse or escape) and in tests. sql.Drivers()
lists the registered names, sorted.
The DSN contains credentials, so it should come from the environment or a secrets store, never from source code (the official guide on opening a handle says the same), and it must not be logged.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"time"
"example.com/shelf/internal/fakedb"
)
func main() {
fake := fakedb.New()
// A real program imports its driver for the side effect instead:
// import _ "github.com/jackc/pgx/v5/stdlib"
// and the driver registers itself in an init function.
sql.Register("fakedb", fake)
dsn := os.Getenv("SHELF_DSN") // never hard-code credentials
if dsn == "" {
dsn = "fakedb://shelf@localhost/shelf"
}
db, err := sql.Open("fakedb", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
fmt.Println("drivers:", sql.Drivers())
fmt.Println("connections opened after sql.Open:", fake.Connects())
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
log.Fatal(err)
}
fmt.Println("connections opened after PingContext:", fake.Connects())
// sql.OpenDB takes a driver.Connector and skips the registry entirely.
db2 := sql.OpenDB(fakedb.New())
defer db2.Close()
fmt.Printf("same driver type: %T\n", db2.Driver())
}
Output:
drivers: [fakedb]
connections opened after sql.Open: 0
connections opened after PingContext: 1
same driver type: *fakedb.DB
Source: Opening a database handle
Closing and lifetime
DB.Close closes idle connections, waits for queries in progress and prevents new ones. A DB is meant to live as
long as the program, so defer db.Close() in main is enough, and the documentation notes it is rarely necessary to
close it at all. *Rows, *Stmt, *Tx and *Conn are the things that must be closed, because each one holds a
connection (or a prepared statement) that returns to the pool only when it is released.
The DB is a pool
A *sql.DB is not a connection. It is a pool plus a recipe for creating connections, and every method you call
borrows one, uses it and gives it back. That has three consequences that surprise newcomers:
-
Two consecutive statements may run on different connections. Session state (
SET search_path,USE, temporary tables, advisory locks,BEGINwritten as SQL text) does not carry over. Use a transaction or a dedicatedConnwhen statements must share a session. -
Concurrency is free. The handle is safe for use by many goroutines; the pool grows to meet demand.
-
The pool can be exhausted. A query that holds a connection (a
Rowsyou did not close, aTxyou forgot to end) starves everyone else once a limit is reached.
Settings
| Method | Meaning | Default |
|---|---|---|
|
Upper bound on connections in use plus idle. Further requests wait. |
0: unlimited |
|
How many idle connections to keep for reuse. Reduced to the open limit if that is smaller. |
2 ("currently 2", which "may change") |
|
A connection older than |
0: no limit |
|
An idle connection unused for |
0: no limit |
Unlimited open connections is rarely what a service wants: a traffic spike then becomes a connection storm against the
database. The usual approach is to set SetMaxOpenConns to a number the database can sustain divided by the number of
application instances, to keep SetMaxIdleConns equal to or close to it so that connections are reused instead of
reopened, and to set a SetConnMaxLifetime of a few minutes to an hour. The right numbers come from measuring; see
Managing connections for the official guidance, and
Diagnosing and Fixing Slow Queries for finding out whether waiting for a
connection or the query itself is the cost.
DB.Stats() returns a snapshot. WaitCount and WaitDuration count requests that had to wait for a connection, and
MaxIdleClosed, MaxIdleTimeClosed and MaxLifetimeClosed count why connections were closed. Export them as metrics
(for example through the Prometheus pages of this site) and a pool that is too small becomes obvious.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
"example.com/shelf/internal/fakedb"
)
func count(ctx context.Context, db *sql.DB) {
var n int
if err := db.QueryRowContext(ctx, "SELECT count(*) FROM books").Scan(&n); err != nil {
log.Fatal(err)
}
}
func main() {
ctx := context.Background()
// Default: up to two idle connections are kept, so sequential use reuses one.
fake := fakedb.New()
db := sql.OpenDB(fake)
for range 3 {
count(ctx, db)
}
fmt.Println("default idle limit: connections opened =", fake.Connects())
db.Close()
// SetMaxIdleConns(0) closes a connection after every use.
fake = fakedb.New()
db = sql.OpenDB(fake)
db.SetMaxIdleConns(0)
for range 3 {
count(ctx, db)
}
fmt.Println("no idle connections: connections opened =", fake.Connects())
db.Close()
// A hard ceiling: with two connections in use, a third request must wait.
db = sql.OpenDB(fakedb.New())
db.SetMaxOpenConns(2)
c1, err := db.Conn(ctx)
if err != nil {
log.Fatal(err)
}
c2, err := db.Conn(ctx)
if err != nil {
log.Fatal(err)
}
waitCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
defer cancel()
_, err = db.Conn(waitCtx)
fmt.Println("third connection:", err)
s := db.Stats()
fmt.Printf("max=%d open=%d inUse=%d idle=%d waitCount=%d\n",
s.MaxOpenConnections, s.OpenConnections, s.InUse, s.Idle, s.WaitCount)
c1.Close() // returns the connection to the pool
c2.Close()
s = db.Stats()
fmt.Printf("after Close: open=%d inUse=%d idle=%d\n", s.OpenConnections, s.InUse, s.Idle)
db.Close()
}
Output:
default idle limit: connections opened = 1
no idle connections: connections opened = 3
third connection: context deadline exceeded
max=2 open=2 inUse=2 idle=0 waitCount=1
after Close: open=2 inUse=0 idle=2
Source: Managing connections
The third block shows the point of SetMaxOpenConns: with two connections checked out, a third caller waits, and it
waits under its own context, so a context with a deadline turns pool exhaustion into an error instead of a hang.
WaitCount recorded the wait.
Dedicated connections
db.Conn(ctx) checks one connection out of the pool and returns a *sql.Conn with the same ExecContext,
QueryContext, QueryRowContext, PrepareContext, BeginTx and PingContext methods. Everything run on it uses that
one session until Close returns it to the pool. Use it for statements that must share state, such as a temporary
table or an advisory lock. Conn.Raw hands the underlying driver.Conn to a function, for driver-specific features
(COPY, notifications) that database/sql has no vocabulary for; the connection must not be used outside the function.
package main
import (
"context"
"database/sql"
"fmt"
"example.com/shelf/internal/fakedb"
)
func main() {
fake := fakedb.New()
db := sql.OpenDB(fake)
defer db.Close()
// Whether names are honoured depends on the driver and the database.
// This driver only logs them and binds by position.
var n int
err := db.QueryRowContext(context.Background(), "SELECT copies FROM books WHERE isbn = ?",
sql.Named("isbn", "9780134190440")).Scan(&n)
fmt.Println(n, err)
fmt.Println(fake.Log()[0])
// A dedicated connection keeps session state (SET, temp tables, locks).
conn, err := db.Conn(context.Background())
if err != nil {
fmt.Println(err)
return
}
defer conn.Close()
cols, err := func() ([]string, error) {
rows, err := conn.QueryContext(context.Background(), "SELECT id, isbn, title, year FROM books ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
return rows.Columns()
}()
fmt.Println(cols, err)
}
Output:
3 <nil>
query SELECT copies FROM books WHERE isbn = ? [isbn="9780134190440"]
[id isbn title year] <nil>
Source: sql.Named and Managing connections
The same program shows sql.Named: a NamedArg is passed with the other arguments, and whether names are honoured
depends on the driver and the database (this fake driver binds by position and only logs the name).
Executing statements
Exec and ExecContext send a statement that returns no rows (INSERT, UPDATE, DELETE, DDL) and return a
sql.Result:
-
RowsAffected()is the number of rows changed. Check it: anUPDATEthat matched nothing is not an error. -
LastInsertId()is the generated key, but only for drivers and databases that support it (MySQL and SQLite style). Others return an error. PostgreSQL has no such concept and uses anINSERTstatement with aRETURNING idclause andQueryRowContext.
Arguments follow the statement text and are bound as parameters, never pasted into the SQL (see SQL injection and placeholders). The
default argument conversion accepts int64, float64, bool, []byte, string, time.Time, nil and anything that
implements driver.Valuer; the int family and other basic kinds are converted automatically. Anything else fails
before the statement is sent: sql: converting argument $1 type: ....
The example also defines a Value/Scan pair on a defined type, the standard way to store a domain type (here the
shelf module’s ISBN) in a plain column, and shows a driver-specific error recognised with errors.As:
package main
import (
"context"
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"log"
"example.com/shelf/internal/fakedb"
)
// ISBN stores itself as a plain string column. In the shelf module these two
// methods would live next to the type in package catalog.
type ISBN string
// Value implements driver.Valuer: what the driver receives for this type.
func (i ISBN) Value() (driver.Value, error) {
if len(i) != 13 {
return nil, fmt.Errorf("isbn %q: want 13 digits", string(i))
}
return string(i), nil
}
// Scan implements sql.Scanner: how a column value becomes an ISBN.
func (i *ISBN) Scan(src any) error {
s, ok := src.(string)
if !ok {
return fmt.Errorf("isbn: cannot scan %T", src)
}
*i = ISBN(s)
return nil
}
func main() {
ctx := context.Background()
db := sql.OpenDB(fakedb.New())
defer db.Close()
const insert = "INSERT INTO books (isbn, title, year) VALUES (?, ?, ?)"
res, err := db.ExecContext(ctx, insert, ISBN("9780596517748"), "JavaScript: The Good Parts", 2008)
if err != nil {
log.Fatal(err)
}
id, _ := res.LastInsertId()
n, _ := res.RowsAffected()
fmt.Println("inserted id", id, "rows affected", n)
// A NULL year: pass nil, or a sql.Null[int64] whose Valid is false.
_, err = db.ExecContext(ctx, insert, ISBN("9781098122980"), "Concurrency in Go", sql.Null[int64]{})
fmt.Println("insert with NULL year:", err)
// A duplicate key comes back as the driver's own error type.
_, err = db.ExecContext(ctx, insert, ISBN("9780596517748"), "Duplicate", 2008)
var dbErr *fakedb.Error
if errors.As(err, &dbErr) {
fmt.Println("driver error code:", dbErr.Code)
}
// Valuer errors surface before the statement is sent.
_, err = db.ExecContext(ctx, insert, ISBN("123"), "Bad", 2000)
fmt.Println(err)
res, err = db.ExecContext(ctx, "UPDATE books SET copies = copies + ? WHERE isbn = ?", 2, ISBN("9780134190440"))
if err != nil {
log.Fatal(err)
}
n, _ = res.RowsAffected()
fmt.Println("update affected", n)
var copies int
if err := db.QueryRowContext(ctx, "SELECT copies FROM books WHERE isbn = ?", ISBN("9780134190440")).Scan(&copies); err != nil {
log.Fatal(err)
}
fmt.Println("copies now", copies)
// Scanner: the isbn column is scanned straight into an ISBN.
var (
id2 int64
isbn ISBN
title string
year sql.Null[int64]
)
err = db.QueryRowContext(ctx, "SELECT id, isbn, title, year FROM books WHERE isbn = ?", "9781617291784").
Scan(&id2, &isbn, &title, &year)
fmt.Printf("scanned %T %q %v\n", isbn, isbn, err)
// This driver counts placeholders itself; see the prepared statement
// example for the check database/sql performs.
_, err = db.ExecContext(ctx, "DELETE FROM books WHERE isbn = ?")
fmt.Println(err)
}
Output:
inserted id 4 rows affected 1
insert with NULL year: <nil>
driver error code: 23505
sql: converting argument $1 type: isbn "123": want 13 digits
update affected 1
copies now 5
scanned main.ISBN "9781617291784" <nil>
fakedb: query has 1 placeholders but got 0 arguments
sql.Null[int64]{} (zero value, Valid false) is stored as NULL; a nil pointer or untyped nil does the same. The
driver’s own error type is reached through errors.As, which is how code distinguishes a duplicate key from an outage
(each driver documents its error type and codes). DDL and migrations use the same Exec call, but real projects keep
them in a migration tool; see Evolving the Database Model.
Querying
A single row
QueryRow/QueryRowContext returns a *sql.Row whose Scan reports the error from the query as well, so the usual
shape is one chained call. When no row matches, Scan returns sql.ErrNoRows, which is a normal outcome, not a failure,
and is tested with errors.Is. If several rows match, the first is used and the rest are discarded.
Several rows
Query/QueryContext returns a *sql.Rows. The loop has four fixed parts, and forgetting any of them is a classic
bug:
-
defer rows.Close()immediately after checking the error fromQuery; an openRowsholds a connection. -
for rows.Next()advances and returns false at the end or on failure. -
rows.Scan(...)inside the loop, checked. -
rows.Err()after the loop: it is the only way to tell "no more rows" from "the connection broke half-way". Without it, a failure looks like a smaller result set.
When Next returns false and there are no more result sets, Rows closes itself, so the deferred Close is a safeguard
for early returns and panics. Close is idempotent.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"example.com/shelf/internal/fakedb"
)
// Book mirrors the books table; Year is NULL for some rows.
type Book struct {
ID int64
ISBN string
Title string
Year sql.Null[int64]
}
func getBook(ctx context.Context, db *sql.DB, isbn string) (Book, error) {
var b Book
err := db.QueryRowContext(ctx,
"SELECT id, isbn, title, year FROM books WHERE isbn = ?", isbn,
).Scan(&b.ID, &b.ISBN, &b.Title, &b.Year)
if errors.Is(err, sql.ErrNoRows) {
return Book{}, fmt.Errorf("book %s: not found", isbn)
}
return b, err
}
func searchBooks(ctx context.Context, db *sql.DB, pattern string) ([]Book, error) {
rows, err := db.QueryContext(ctx,
"SELECT id, isbn, title, year FROM books WHERE title LIKE ? ORDER BY id", pattern)
if err != nil {
return nil, err
}
defer rows.Close()
var books []Book
for rows.Next() {
var b Book
if err := rows.Scan(&b.ID, &b.ISBN, &b.Title, &b.Year); err != nil {
return nil, err
}
books = append(books, b)
}
// Next returned false: either the rows ran out or something failed.
if err := rows.Err(); err != nil {
return nil, err
}
return books, nil
}
func main() {
ctx := context.Background()
fake := fakedb.New()
db := sql.OpenDB(fake)
defer db.Close()
b, err := getBook(ctx, db, "9781617291784")
fmt.Printf("%+v %v\n", b, err)
if b.Year.Valid {
fmt.Println("year:", b.Year.V)
} else {
fmt.Println("year: unknown (NULL)")
}
_, err = getBook(ctx, db, "0000000000000")
fmt.Println(err)
books, err := searchBooks(ctx, db, "%go%")
fmt.Println(len(books), "matches", err)
for _, b := range books {
fmt.Println(" ", b.ID, b.Title)
}
// The driver fails after two rows: Next just returns false.
fake.FailRowsAfter = 2
books, err = searchBooks(ctx, db, "%go%")
fmt.Println(len(books), "books, err =", err)
}
Output:
{ID:3 ISBN:9781617291784 Title:Go in Action Year:{V:0 Valid:false}} <nil>
year: unknown (NULL)
book 0000000000000: not found
3 matches <nil>
1 The Go Programming Language
2 Learning Go
3 Go in Action
0 books, err = fakedb: connection reset by peer
Source: Querying for data
The last two lines are the reason rows.Err() exists: the driver fails after two rows, Next just returns false, and
only Err reveals the cause.
Columns, types and multiple result sets
rows.Columns() returns the column names. rows.ColumnTypes() returns []*sql.ColumnType with
DatabaseTypeName, Nullable, Length, DecimalSize and ScanType, filled in as far as the driver provides it, for
generic tools that do not know the schema in advance. Statements that return several result sets (some stored procedures,
SQL Server batches) are read with rows.NextResultSet() in a loop. sql.RawBytes scans a column without copying, but the
slice is only valid until the next Next, Scan or Close; copy it if it must live longer.
Streaming rows with range-over-func
Since Go 1.23, the four-part loop can be packaged as an iter.Seq2 so that callers write a plain range loop and the
cleanup cannot be forgotten (see
Iterators and Range-over-Func). The error is delivered
as the last element, the usual convention for fallible sequences:
package main
import (
"context"
"database/sql"
"fmt"
"iter"
"example.com/shelf/internal/fakedb"
)
type Book struct {
ID int64
ISBN string
Title string
Year sql.Null[int64]
}
// Books streams the catalogue. The caller can stop early with break; the
// deferred Close then releases the connection. A query or iteration error is
// delivered as the last element.
func Books(ctx context.Context, db *sql.DB) iter.Seq2[Book, error] {
return func(yield func(Book, error) bool) {
rows, err := db.QueryContext(ctx, "SELECT id, isbn, title, year FROM books ORDER BY id")
if err != nil {
yield(Book{}, err)
return
}
defer rows.Close()
for rows.Next() {
var b Book
if err := rows.Scan(&b.ID, &b.ISBN, &b.Title, &b.Year); err != nil {
yield(Book{}, err)
return
}
if !yield(b, nil) {
return
}
}
if err := rows.Err(); err != nil {
yield(Book{}, err)
}
}
}
func main() {
ctx := context.Background()
fake := fakedb.New()
db := sql.OpenDB(fake)
defer db.Close()
for b, err := range Books(ctx, db) {
if err != nil {
fmt.Println("error:", err)
break
}
fmt.Println(b.ID, b.Title)
if b.ID == 2 {
break // early exit: rows are closed by the deferred call
}
}
fmt.Println("in use after break:", db.Stats().InUse)
fake.FailRowsAfter = 2
for b, err := range Books(ctx, db) {
if err != nil {
fmt.Println("error:", err)
break
}
fmt.Println(b.ID, b.Title)
}
}
Output:
1 The Go Programming Language
2 Learning Go
in use after break: 0
1 The Go Programming Language
2 Learning Go
error: fakedb: connection reset by peer
Source: database/sql Rows and iter
The break leaves the function body early, the deferred rows.Close() runs, and the pool shows no connection in use.
Older tutorials show only the explicit loop; both forms are equivalent.
Scanning values and NULL
Scan copies each column into the matching destination pointer. The number of destinations must equal the number of
columns. Conversions follow the package documentation: a destination of the same kind is assigned directly; strings and
numbers convert into each other as long as nothing is lost (a column holding "300" scans into a uint16 but not into a
uint8, with an out-of-range error); *[]byte receives a copy; *any receives the driver value unchanged;
time.Time columns scan into *time.Time; a *sql.RawBytes borrows the driver’s memory.
A SQL NULL cannot be stored in a plain string, int or bool: the scan fails with
converting NULL to int64 is unsupported. There are four ways to handle nullable columns:
| Approach | Example | Notes |
|---|---|---|
|
|
Generic; |
The older typed wrappers |
|
Same idea, one type per kind. Still fully supported; |
A pointer |
|
|
Make the column |
|
Best when a sentinel value is acceptable. |
sql.Null[T] also implements driver.Valuer, so it is the same type you pass back as an argument (as the Exec
example above did). Your own types participate by implementing sql.Scanner (Scan(src any) error) for reading and
driver.Valuer (Value() (driver.Value, error)) for writing; a Scanner receives the raw driver value (int64,
float64, bool, []byte, string, time.Time or nil) and must handle nil and the wrong type with an error. A
common use is a JSON column: Value marshals, Scan unmarshals (see
encoding/json).
Go 1.27 lists uuid.UUID among the destinations Scan understands (a canonical string or 16 bytes), alongside the new
standard uuid package, and exports the conversion routine as sql.ConvertAssign so that drivers can reuse it. Both are
visible directly:
package main
import (
"database/sql"
"database/sql/driver"
"fmt"
"uuid"
)
func main() {
// ConvertAssign applies the same conversions Rows.Scan uses.
var id uuid.UUID
err := sql.ConvertAssign(driver.ScanContext{}, &id, "0190a0a0-7000-7000-8000-000000000001")
fmt.Println(id, err)
var s string
err = sql.ConvertAssign(driver.ScanContext{}, &s, int64(2021))
fmt.Printf("%q %v\n", s, err)
var small uint8
err = sql.ConvertAssign(driver.ScanContext{}, &small, "300")
fmt.Println(small, err)
}
Output:
0190a0a0-7000-7000-8000-000000000001 <nil>
"2021" <nil>
0 converting driver.Value type string ("300") to a uint8: value out of range
Source: ConvertAssign and Rows.Scan
Common scan errors
These are the messages you will meet, copied from real runs of the program below:
package main
import (
"context"
"database/sql"
"fmt"
"time"
"example.com/shelf/internal/fakedb"
)
func main() {
ctx := context.Background()
db := sql.OpenDB(fakedb.New())
defer db.Close()
const q = "SELECT id, isbn, title, year FROM books WHERE isbn = ?"
// 1. Wrong number of destinations.
var id int64
var isbn, title string
err := db.QueryRowContext(ctx, q, "9781617291784").Scan(&id, &isbn, &title)
fmt.Println("1:", err)
// 2. NULL into a plain Go type.
var year int64
err = db.QueryRowContext(ctx, q, "9781617291784").Scan(&id, &isbn, &title, &year)
fmt.Println("2:", err)
// 3. A value that does not fit the destination (a title is not a number).
var n int
err = db.QueryRowContext(ctx, q, "9781492077213").Scan(&id, &isbn, &n, &year)
fmt.Println("3:", err)
// 4. Forgetting rows.Close keeps the connection busy.
rows, _ := db.QueryContext(ctx, "SELECT id, isbn, title, year FROM books ORDER BY id")
rows.Next() // read one row and walk away without Close
fmt.Println("4: in use after abandoning rows:", db.Stats().InUse)
rows.Close()
fmt.Println("4: in use after Close:", db.Stats().InUse)
// 5. Using db instead of tx inside a transaction starves a small pool.
db.SetMaxOpenConns(1)
tx, _ := db.BeginTx(ctx, nil)
short, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
err = db.QueryRowContext(short, "SELECT count(*) FROM books").Scan(&n)
fmt.Println("5:", err)
tx.Rollback()
}
Output:
1: sql: expected 4 destination arguments in Scan, not 3
2: sql: Scan error on column index 3, name "year": converting NULL to int64 is unsupported
3: sql: Scan error on column index 2, name "title": converting driver.Value type string ("Learning Go") to a int: invalid syntax
4: in use after abandoning rows: 1
4: in use after Close: 0
5: context deadline exceeded
Source: Rows.Scan
The first three are programming errors reported by Scan (number of destinations, NULL into a plain type, a value that
does not convert). The fourth and fifth are pool problems with no error at all in the first case: an abandoned Rows keeps
a connection InUse until it is closed, and a query that bypasses the open transaction (db instead of tx) needs a
second connection, which with SetMaxOpenConns(1) never arrives, so only the context saves it. Use the transaction’s
methods inside the transaction.
Prepared statements
A prepared statement is SQL sent to the server once and executed many times with different parameters. Two situations
justify it: a statement executed repeatedly in a loop, and a driver or database where parameter binding requires a
prepare step. Otherwise Exec and Query with arguments are just as safe, and are simpler: they prepare on the fly or
use a driver fast path.
db.PrepareContext returns a *sql.Stmt with ExecContext, QueryContext and QueryRowContext. Points that differ
from what the name suggests:
-
A
Stmtis bound to the DB, not to one connection. If the connection it was prepared on is busy or gone,database/sqltransparently prepares it again on another connection. -
Stmtis safe for concurrent use and must be closed (defer stmt.Close()) to release server resources. -
tx.PrepareContextprepares inside a transaction (closed when the transaction ends), andtx.StmtContext(ctx, stmt)adapts an existing statement to run on the transaction’s connection. -
database/sqlchecks the number of arguments against the driver’sNumInputand reportssql: expected 2 arguments, got 1before sending anything.
The driver log shows exactly one prepare followed by three executions, then the argument check:
package main
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
"example.com/shelf/internal/fakedb"
)
func main() {
ctx := context.Background()
fake := fakedb.New()
db := sql.OpenDB(fake)
defer db.Close()
// Prepare once, execute many times; the statement is bound to the pool
// and re-prepared on other connections as needed.
stmt, err := db.PrepareContext(ctx, "UPDATE books SET copies = copies + ? WHERE isbn = ?")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
for _, isbn := range []string{"9780134190440", "9781492077213", "9781617291784"} {
res, err := stmt.ExecContext(ctx, 1, isbn)
if err != nil {
log.Fatal(err)
}
n, _ := res.RowsAffected()
fmt.Println(isbn, "rows affected:", n)
}
// database/sql checks the argument count against NumInput.
_, err = stmt.ExecContext(ctx, 1)
fmt.Println(err)
fmt.Println("--- what the driver saw")
for _, line := range fake.Log() {
fmt.Println(strings.TrimSpace(line))
}
}
Output:
9780134190440 rows affected: 1
9781492077213 rows affected: 1
9781617291784 rows affected: 1
sql: expected 2 arguments, got 1
--- what the driver saw
prepare UPDATE books SET copies = copies + ? WHERE isbn = ?
exec UPDATE books SET copies = copies + ? WHERE isbn = ? [1 "9780134190440"]
exec UPDATE books SET copies = copies + ? WHERE isbn = ? [1 "9781492077213"]
exec UPDATE books SET copies = copies + ? WHERE isbn = ? [1 "9781617291784"]
Source: Using prepared statements
Transactions
db.BeginTx(ctx, opts) (or Begin) starts a transaction and returns a *sql.Tx pinned to one connection. Everything
done through the Tx (ExecContext, QueryContext, QueryRowContext, PrepareContext, StmtContext) runs inside it.
Commit or Rollback ends it and returns the connection to the pool; after that every method returns sql.ErrTxDone.
Do not issue BEGIN and COMMIT as SQL text through the DB: statements may land on different connections.
sql.TxOptions carries Isolation and ReadOnly. The levels are constants of type sql.IsolationLevel:
| Constant | Notes |
|---|---|
|
The driver or database default. The zero value. |
|
Read committed is the default in most servers; |
|
Snapshot-style reads; names and exact guarantees differ by database. |
|
Strongest levels; callers must be ready to retry on serialization failures. |
A driver rejects a level or ReadOnly flag it does not support with an error from BeginTx. What each level allows is a
database topic: see Transaction Control in the SQL Reference.
The robust shape is a helper that owns the commit-or-rollback decision, so call sites cannot forget it. Rollback after a
successful Commit is harmless (it returns ErrTxDone), which allows the common defer tx.Rollback() idiom as well; the
helper below also handles a panic and joins a rollback failure to the original error with errors.Join:
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"example.com/shelf/internal/fakedb"
)
// inTx runs fn inside a transaction: it commits when fn returns nil and rolls
// back on any error or panic.
func inTx(ctx context.Context, db *sql.DB, opts *sql.TxOptions, fn func(*sql.Tx) error) (err error) {
tx, err := db.BeginTx(ctx, opts)
if err != nil {
return err
}
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p)
}
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
err = errors.Join(err, rbErr)
}
}
}()
if err = fn(tx); err != nil {
return err
}
return tx.Commit()
}
func copies(ctx context.Context, q interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, isbn string) int {
var n int
if err := q.QueryRowContext(ctx, "SELECT copies FROM books WHERE isbn = ?", isbn).Scan(&n); err != nil {
log.Fatal(err)
}
return n
}
// lend moves one copy of a book out of the catalogue.
func lend(ctx context.Context, tx *sql.Tx, isbn string) error {
if _, err := tx.ExecContext(ctx, "UPDATE books SET copies = copies + ? WHERE isbn = ?", -1, isbn); err != nil {
return err
}
if copies(ctx, tx, isbn) < 0 {
return errors.New("no copies left")
}
return nil
}
func main() {
ctx := context.Background()
db := sql.OpenDB(fakedb.New())
defer db.Close()
const goBook = "9781617291784" // one copy on the shelf
err := inTx(ctx, db, nil, func(tx *sql.Tx) error { return lend(ctx, tx, goBook) })
fmt.Println("first loan:", err, "- copies:", copies(ctx, db, goBook))
err = inTx(ctx, db, nil, func(tx *sql.Tx) error { return lend(ctx, tx, goBook) })
fmt.Println("second loan:", err, "- copies:", copies(ctx, db, goBook))
// After Commit or Rollback the Tx is finished.
tx, _ := db.BeginTx(ctx, nil)
_ = tx.Commit()
fmt.Println("commit twice:", tx.Commit())
_, err = tx.ExecContext(ctx, "DELETE FROM books WHERE isbn = ?", goBook)
fmt.Println("use after commit:", err, errors.Is(err, sql.ErrTxDone))
// Options: a read-only transaction rejects writes.
err = inTx(ctx, db, &sql.TxOptions{ReadOnly: true}, func(tx *sql.Tx) error { return lend(ctx, tx, goBook) })
fmt.Println("read-only:", err)
// The driver decides which isolation levels exist.
_, err = db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead})
fmt.Println("repeatable read:", err)
fmt.Println(sql.LevelSerializable, sql.LevelReadCommitted)
}
Output:
first loan: <nil> - copies: 0
second loan: no copies left - copies: 0
commit twice: sql: transaction has already been committed or rolled back
use after commit: sql: transaction has already been committed or rolled back true
read-only: fakedb: cannot execute in a read-only transaction (25006)
repeatable read: fakedb: isolation level 4 not supported
Serializable Read Committed
Source: Executing transactions
Walk through the output: the first loan takes the last copy and commits; the second loan makes the stock negative, returns
an error, and the helper rolls back, so the count is still 0 (not -1). A finished Tx returns ErrTxDone. The
read-only transaction and the unsupported isolation level are rejected by this driver; a real driver and server do the
same, with their own messages.
Retry on a serialization failure or deadlock is the caller’s job: run the whole function again in a new transaction, with a bound on attempts and (for serializable work) a small randomised back-off. Keep transactions short: a transaction holds a connection, and probably locks, until it ends.
Context: timeouts and cancellation
Every blocking method has a Context twin: PingContext, ExecContext, QueryContext, QueryRowContext,
PrepareContext, BeginTx (all added in Go 1.8). Use them everywhere; the context-less methods are shortcuts for
context.Background() and are only appropriate in main or tests. Older tutorials, and any material written before Go 1.8,
use the context-less forms exclusively; they cannot be cancelled and are the usual cause of requests that hang after a
client leaves.
What cancellation does:
-
A context that expires while waiting for a connection fails the call (the pool example above).
-
A context that expires during a query asks the driver to stop it, and the call returns the context’s error (
context.DeadlineExceededorcontext.Canceled, matched witherrors.Is). Whether the server stops work is up to the driver and database. -
The context passed to
BeginTxgoverns the whole transaction: when it is cancelled,database/sqlrolls the transaction back, and later calls on it fail.Commitreturns the context’s error if it runs first, orErrTxDoneonce the rollback has happened. -
The context passed to
QueryContextalso governs iteration: cancelling it makesRows.Nextreturn false andRows.Errreturn the context’s error, so the loop shape above needs no special case.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"time"
"example.com/shelf/internal/fakedb"
)
func main() {
db := sql.OpenDB(fakedb.New())
defer db.Close()
// 1. A deadline on one query.
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
rows, err := db.QueryContext(ctx, "SELECT slow()")
fmt.Println("slow query:", err, errors.Is(err, context.DeadlineExceeded), time.Since(start) < 2*time.Second)
if rows != nil {
rows.Close()
}
// 2. Explicit cancellation, with a cause.
cctx, cancelCause := context.WithCancelCause(context.Background())
cancelCause(errors.New("client went away"))
err = db.PingContext(cctx)
fmt.Println("ping:", err, "| cause:", context.Cause(cctx))
// 3. Cancelling the context passed to BeginTx rolls the transaction back.
txCtx, cancelTx := context.WithCancel(context.Background())
tx, err := db.BeginTx(txCtx, nil)
if err != nil {
log.Fatal(err)
}
if _, err := tx.ExecContext(txCtx, "DELETE FROM books WHERE isbn = ?", "9780134190440"); err != nil {
log.Fatal(err)
}
cancelTx()
time.Sleep(100 * time.Millisecond) // let the rollback goroutine run
fmt.Println("commit after cancel:", tx.Commit())
var n int
if err := db.QueryRow("SELECT count(*) FROM books").Scan(&n); err != nil {
log.Fatal(err)
}
fmt.Println("books still there:", n)
}
Output:
slow query: context deadline exceeded true true
ping: context canceled | cause: client went away
commit after cancel: sql: transaction has already been committed or rolled back
books still there: 3
The pattern in a service is to derive a context with a time budget from the request context (r.Context() is cancelled
when the client disconnects), pass it to every database call and let errors.Is(err, context.DeadlineExceeded) select the
HTTP status. Contexts are covered in depth in Context; the handler below is the
shelfd endpoint in miniature, with sql.ErrNoRows mapped to 404 and any other error to 500 without leaking its text:
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"time"
"example.com/shelf/internal/fakedb"
)
type Book struct {
ISBN string `json:"isbn"`
Title string `json:"title"`
Year *int64 `json:"year,omitempty"`
}
type server struct {
db *sql.DB
log *slog.Logger
}
func (s *server) getBook(w http.ResponseWriter, r *http.Request) {
// r.Context() is cancelled when the client disconnects; add a budget.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
var b Book
var year sql.Null[int64]
var id int64
err := s.db.QueryRowContext(ctx,
"SELECT id, isbn, title, year FROM books WHERE isbn = ?", r.PathValue("isbn"),
).Scan(&id, &b.ISBN, &b.Title, &year)
switch {
case errors.Is(err, sql.ErrNoRows):
http.Error(w, "no such book", http.StatusNotFound)
return
case err != nil:
s.log.ErrorContext(ctx, "query failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if year.Valid {
b.Year = &year.V
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(b)
}
func main() {
db := sql.OpenDB(fakedb.New())
defer db.Close()
s := &server{db: db, log: slog.New(slog.DiscardHandler)}
mux := http.NewServeMux()
mux.HandleFunc("GET /books/{isbn}", s.getBook)
ts := httptest.NewServer(mux)
defer ts.Close()
for _, isbn := range []string{"9781492077213", "9781617291784", "0000000000000"} {
resp, err := http.Get(ts.URL + "/books/" + isbn)
if err != nil {
fmt.Println(err)
return
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%d %s", resp.StatusCode, body)
}
}
Output:
200 {"isbn":"9781492077213","title":"Learning Go","year":2021}
200 {"isbn":"9781617291784","title":"Go in Action"}
404 no such book
SQL injection and placeholders
SQL injection happens when untrusted input becomes part of the SQL text, so that the input is parsed as SQL. The fix is structural, not a matter of escaping: send the statement text and the values separately, with placeholders for the values. The database then never parses the value as SQL.
// WRONG: concatenation. Input such as x' OR '1'='1 changes the query.
query := "SELECT id, isbn, title, year FROM books WHERE isbn = '" + isbn + "'"
// RIGHT: the text is constant, the value is an argument.
rows, err := db.QueryContext(ctx, "SELECT id, isbn, title, year FROM books WHERE isbn = ?", isbn)
Source: Avoiding SQL injection risk
(Not run on its own; the next program runs both halves against the fake driver.) Use fmt.Sprintf for SQL only with
trusted constants. The rules that follow from that:
-
Values always go through placeholders, in
WHERE,VALUES,SET,LIMIT,OFFSET. That includesLIKEpatterns: bind"%" + term + "%"as the argument, but remember that%and_insidetermare still wildcards; escape them if the term must match literally. -
Identifiers cannot be parameters. Table names, column names and sort directions are not values. If a caller chooses one (the column to sort by), map the input to a fixed set of allowed constants and reject everything else, as
orderBydoes below. Quoting functions exist in some drivers, but an allow-list is simpler and safer. -
INlists need one placeholder per element; generate the placeholder text (a constant shape) and pass the values as arguments, as in the last line below. Some databases offer array parameters instead (for example PostgreSQL’s= ANY($1)with a driver that supports arrays). -
Validate and type input anyway (parse the ISBN into the
ISBNtype before it reaches a query), and give the database user the least privilege it needs, so a bug cannot become a disaster. Dependency vulnerabilities in drivers are found withgovulncheck; see Crypto and Security.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
"example.com/shelf/internal/fakedb"
)
// orderBy chooses a column name. Identifiers cannot be bound as parameters,
// so a fixed allow-list is the safe way to let callers pick one.
func orderBy(col string) (string, error) {
switch col {
case "title", "year", "isbn":
return col, nil
}
return "", fmt.Errorf("cannot order by %q", col)
}
// placeholders returns "?, ?, ?" for n values, for use in an IN list.
func placeholders(n int) string {
return strings.TrimSuffix(strings.Repeat("?, ", n), ", ")
}
func main() {
ctx := context.Background()
fake := fakedb.New()
db := sql.OpenDB(fake)
defer db.Close()
hostile := "x' OR '1'='1"
// WRONG: the input becomes part of the SQL text.
unsafe := "SELECT id, isbn, title, year FROM books WHERE isbn = '" + hostile + "'"
fmt.Println("concatenated:", unsafe)
// RIGHT: the text is fixed; the value travels separately.
rows, err := db.QueryContext(ctx, "SELECT id, isbn, title, year FROM books WHERE isbn = ?", hostile)
if err != nil {
log.Fatal(err)
}
n := 0
for rows.Next() {
n++
}
if err := rows.Err(); err != nil {
log.Fatal(err)
}
rows.Close()
fmt.Println("parameterized rows matched:", n)
fmt.Println(strings.Join(fake.Log(), "\n"))
for _, c := range []string{"title", "title; DROP TABLE books"} {
col, err := orderBy(c)
fmt.Printf("orderBy(%q) = %q, %v\n", c, col, err)
}
isbns := []any{"9780134190440", "9781492077213", "9781617291784"}
in := "SELECT id, isbn, title, year FROM books WHERE isbn IN (" + placeholders(len(isbns)) + ")"
fmt.Println(in, "with", len(isbns), "arguments")
}
Output:
concatenated: SELECT id, isbn, title, year FROM books WHERE isbn = 'x' OR '1'='1'
parameterized rows matched: 0
query SELECT id, isbn, title, year FROM books WHERE isbn = ? ["x' OR '1'='1"]
orderBy("title") = "title", <nil>
orderBy("title; DROP TABLE books") = "", cannot order by "title; DROP TABLE books"
SELECT id, isbn, title, year FROM books WHERE isbn IN (?, ?, ?) with 3 arguments
Source: Avoiding SQL injection risk
The fake driver does not interpret SQL, so the first line only shows the string a careless program would have built
(in a real database the quote closes the string literal and the condition '1'='1' is always true, so the query returns every row). The second half is the real
mechanism: the log shows the driver received the text with a ? and the hostile string as a single argument, and
the query matched nothing. The official guide
Avoiding SQL injection risk gives the same advice.
Rows.Err and the upcoming sqlrowserr analyzer
Forgetting rows.Err() is the most common database/sql bug, because the compiler, go vet (in Go 1.27) and the tests of
a healthy database all accept it. The program below omits the check. With the driver set to fail after two rows, it
returns two of three titles and no error:
package main
import (
"context"
"database/sql"
"fmt"
"log"
"example.com/shelf/internal/fakedb"
)
// titles forgets to call rows.Err after the loop.
func titles(ctx context.Context, db *sql.DB) []string {
rows, err := db.QueryContext(ctx, "SELECT id, isbn, title, year FROM books ORDER BY id")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id int64
var isbn, title string
var year sql.Null[int64]
if err := rows.Scan(&id, &isbn, &title, &year); err != nil {
log.Fatal(err)
}
out = append(out, title)
}
return out // BUG: a mid-stream failure looks like a short result
}
func main() {
fake := fakedb.New()
fake.FailRowsAfter = 2
db := sql.OpenDB(fake)
defer db.Close()
fmt.Println(titles(context.Background(), db))
}
Output:
[The Go Programming Language Learning Go]
Running go vet ./... on this program with go1.27.2 prints nothing and exits with status 0: no analyzer in the
shipped vet looks for this mistake (go tool vet help lists no sqlrowserr).
What is coming: the development version of the Go 1.28 release notes (Go 1.28 is not released at the time of writing; it
is expected in February 2027, and its published release-notes page was still an empty draft on 2026-10-18) announces two new
vet analyzers, scannererr for bufio.Scanner.Err and sqlrowserr, the same kind of check for loops around
sql.Rows.Next, so that an iteration error is not mistaken for a shorter result. The analyzer’s own documentation (in the
golang.org/x/tools repository, and listed in the gopls analyzer reference) describes the target: a *sql.Rows assigned
to a local variable from a call such as db.Query and used in a Next loop with no final Rows.Err() check. To avoid false
positives it stays silent when the Rows is passed into or out of the function or assigned to anything other than a local
variable, and gopls lists it as enabled by default. Treat those details as provisional: they come from development sources,
this page could not run the analyzer (it is not part of go1.27.2), and the final wording, the Go version it first ships in
and whether go vet enables it by default should be confirmed in the Go 1.28 release notes when they are published. Until
then, the habit is the fix: always end a Next loop with a check of rows.Err(), or use a wrapper such as the iterator in
this page that cannot omit it.
Source: sqlrowserr analyzer (development version) and Go 1.28 release notes (draft)
Inside a driver
You rarely write a driver, but reading the interfaces explains the behaviour above and lets you write a fake for tests. The
package database/sql/driver defines the required interfaces and many optional ones that database/sql detects with type
assertions.
| Interface | Role |
|---|---|
|
|
|
Optional. |
|
|
|
|
|
Optional context-aware versions that also receive |
|
Optional fast paths that skip the prepare step. A driver returns |
|
A prepared statement; |
|
|
|
Optional. Health checks, "is this connection still usable" before reuse, and cleanup when a connection returns to the pool. |
|
Control how arguments are converted, including |
A connection that has died is reported with driver.ErrBadConn (from the optional interfaces above, never from Open or
Connect), and database/sql then discards it and retries the operation on another connection when that is safe. This
is why a briefly restarted database usually costs a few failed queries rather than a stuck pool.
RowsColumnScanner (Go 1.27)
Until Go 1.27 a driver produced every value as a driver.Value in Rows.Next, and database/sql then converted it into
the destination, so the value passed through that intermediate form before reaching the destination. Go 1.27 lets a driver implement
driver.RowsColumnScanner: NextRow() advances to the next row (returning io.EOF at the end) and
ScanColumn(scanCtx, index, dest) writes a column directly into the user’s destination. database/sql then calls those
two methods instead of Next, and a driver can still implement Next to support older Go versions. A driver that does
not need the optimisation calls the exported sql.ConvertAssign(scanCtx, dest, src) from ScanColumn to reuse the
standard conversions. This is a feature for driver authors; callers see no API change. The
smallest possible implementation (note that Next returns an error, which proves it is never called):
package main
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"log"
)
// rows is a driver.Rows that also implements driver.RowsColumnScanner (Go 1.27).
type rows struct {
cols []string
data [][]driver.Value
pos int
}
func (r *rows) Columns() []string { return r.cols }
func (r *rows) Close() error { return nil }
// Next is still required by driver.Rows; database/sql 1.27 never calls it
// when NextRow and ScanColumn exist.
func (r *rows) Next(dest []driver.Value) error { return fmt.Errorf("unused") }
func (r *rows) NextRow() error {
r.pos++
if r.pos >= len(r.data) {
return io.EOF
}
return nil
}
func (r *rows) ScanColumn(sc driver.ScanContext, i int, dest any) error {
// A real driver could decode the wire bytes straight into dest here,
// skipping the intermediate driver.Value; this one just converts.
return sql.ConvertAssign(sc, dest, r.data[r.pos][i])
}
type conn struct{}
func (conn) Prepare(string) (driver.Stmt, error) { return nil, driver.ErrSkip }
func (conn) Close() error { return nil }
func (conn) Begin() (driver.Tx, error) { return nil, driver.ErrSkip }
func (conn) QueryContext(context.Context, string, []driver.NamedValue) (driver.Rows, error) {
return &rows{
cols: []string{"title", "year"},
data: [][]driver.Value{{"Learning Go", int64(2021)}, {"Go in Action", nil}},
pos: -1,
}, nil
}
type connector struct{}
func (connector) Connect(context.Context) (driver.Conn, error) { return conn{}, nil }
func (connector) Driver() driver.Driver { return nil }
func main() {
db := sql.OpenDB(connector{})
defer db.Close()
rs, err := db.Query("SELECT title, year FROM books")
if err != nil {
log.Fatal(err)
}
defer rs.Close()
for rs.Next() {
var title string
var year sql.Null[int64]
if err := rs.Scan(&title, &year); err != nil {
log.Fatal(err)
}
fmt.Println(title, year.V, year.Valid)
}
fmt.Println("err:", rs.Err())
}
Output:
Learning Go 2021 true
Go in Action 0 false
err: <nil>
Source: RowsColumnScanner
Testing database code
Three techniques, in rising cost and fidelity:
-
Hide the database behind an interface defined where it is used (a
Storeinterface like the one in the shelfcatalogpackage, see Interfaces) and test the logic with an in-memory implementation. This tests everything except the SQL. -
A fake driver, as on this page: small, fast and good for checking error paths (a connection that fails mid-iteration, a duplicate key, a read-only transaction) that are hard to provoke on a real server.
sql.OpenDB(connector)makes injecting one a one-liner. It cannot validate SQL. -
A real database, started by the test (a container, an embedded database, or a CI service) with the schema applied by the same migrations as production, with each test wrapped in a transaction that is rolled back or run against a fresh schema. This is the only technique that checks the SQL itself. See Testing for
TestMain,t.Cleanup,t.Context()and skipping integration tests under-short.
Whatever the technique, check rows.Err(), not-found handling and cancellation in tests; they are the paths that fail in
production.
Pitfalls checklist
-
Creating a
DBper request, or callingOpenin a handler. Create it once. -
Not closing
Rows,Stmt,TxorConnon every path, including errors. Usedeferstraight after the error check. -
Omitting
rows.Err()after the loop (see above). -
defer rows.Close()inside a loop in the same function: the defers pile up until the function returns. Put the query in a function of its own. -
Using
Queryfor statements that return no rows (it leaks a connection untilClose), orExecforSELECT. -
Using
dbinside a function that was handed atx: the statement runs outside the transaction. -
Holding a transaction open across slow work (HTTP calls, user input).
-
Concatenating input into SQL text.
-
Ignoring
RowsAffectedafter an update that must change exactly one row. -
Comparing errors with
==instead oferrors.Is/errors.As; the driver error is usually wrapped. -
Relying on
LastInsertIdwith a driver that does not support it. -
Logging DSNs or full statements with arguments (credentials and personal data).
-
Reading
sql.RawBytesafter the nextNextcall. -
Assuming the same connection is used across calls (session state).
Other data stores from Go
database/sql fits relational databases with SQL and rows. Other stores have their own clients, usually not built on it,
and their own pages on this site:
-
Relational databases and SQL itself: the SQL Reference (queries in Querying Data, modifications in DML modifications, schema in DDL and transactions in Transaction Control), and the cross-cutting pages on Pagination: Offset vs. Keyset, Diagnosing and Fixing Slow Queries and Evolving the Database Model (migrations).
-
Redis: the Go client (go-redis) is covered in Redis: Clients and Client-Side Caching.
-
MongoDB: the Go driver is covered in MongoDB: Connecting from an application and the tooling.
-
Couchbase: the Go SDK is covered in Couchbase: SDKs, connectors and Couchbase Mobile.
-
Choosing between them: Choosing the Right Database.
The Go habits transfer: share one client for the whole program, pass a context.Context to every call, close what you open,
and map store-specific errors to your own sentinel errors at the boundary.
Version notes
-
Go 1.5:
DB.Stats. Go 1.6:SetConnMaxLifetime. -
Go 1.8: context-aware methods (
QueryContext,ExecContext,PrepareContext,BeginTx),TxOptionsand isolation levels,sql.Named,Rows.ColumnTypesandNextResultSet. -
Go 1.9:
sql.ConnandDB.Conn,sql.Out. Go 1.10:sql.OpenDBanddriver.Connector. -
Go 1.13:
NullTime,NullInt32;Conn.Raw. Go 1.15:SetConnMaxIdleTime,Row.Err. -
Go 1.17:
NullInt16,NullByte. -
Go 1.22: generic
sql.Null[T]. -
Go 1.23: range-over-func makes iterator wrappers of
Rowsnatural (iter.Seq2). -
Go 1.27:
sql.ConvertAssign,driver.RowsColumnScanneranddriver.ScanContext(the release notes list only these two additions); theRows.Scandocumentation listsuuid.UUIDdestinations alongside the new standarduuidpackage. Examples were checked withgo 1.27ingo.mod. -
Go 1.28 (expected February 2027, not released): the
sqlrowserrvet analyzer, per its development release notes.
References
-
Accessing relational databases and its guides: Opening a database handle, Managing connections, Querying for data, Executing SQL statements that don’t return data, Using prepared statements, Executing transactions, Canceling in-progress database operations and Avoiding SQL injection risk
-
sqlrowserr analyzer (development version) and Go 1.28 release notes (draft at the time of writing)