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.

flowchart LR App["Your code: QueryContext, ExecContext, BeginTx"] --> DB["sql.DB, safe for concurrent use"] DB --> Pool["Connection pool: idle and in-use connections"] Pool --> Conn1["driver.Conn"] Pool --> Conn2["driver.Conn"] Conn1 --> Server[("Database server")] Conn2 --> Server Driver["Driver module, registered or passed to OpenDB"] -. "creates" .-> Conn1 Driver -. "creates" .-> Conn2

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
}

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.

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"
)

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

$1, $2 and so on

Ordinal; the same $1 may be used twice in one statement. Use a RETURNING clause instead of LastInsertId.

MySQL, MariaDB, SQLite (most drivers)

?

Positional only; repeat the value if it is used twice.

SQL Server

@p1 or @name

Supports sql.Named.

Oracle

:1 or :name

Supports sql.Named and sql.Out for stored-procedure output parameters.

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

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, BEGIN written as SQL text) does not carry over. Use a transaction or a dedicated Conn when 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 Rows you did not close, a Tx you forgot to end) starves everyone else once a limit is reached.

Settings

Method Meaning Default

SetMaxOpenConns(n)

Upper bound on connections in use plus idle. Further requests wait.

0: unlimited

SetMaxIdleConns(n)

How many idle connections to keep for reuse. Reduced to the open limit if that is smaller. n <= 0 keeps none.

2 ("currently 2", which "may change")

SetConnMaxLifetime(d)

A connection older than d is closed lazily before reuse. Useful behind load balancers and to pick up DNS or credential changes.

0: no limit

SetConnMaxIdleTime(d)

An idle connection unused for d is closed lazily. (Go 1.15.)

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

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>

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: an UPDATE that 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 an INSERT statement with a RETURNING id clause and QueryRowContext.

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:

  1. defer rows.Close() immediately after checking the error from Query; an open Rows holds a connection.

  2. for rows.Next() advances and returns false at the end or on failure.

  3. rows.Scan(...) inside the loop, checked.

  4. 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

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

sql.Null[T] (Go 1.22)

var year sql.Null[int64]; fields V and Valid

Generic; T should be a type the driver accepts as a value. Preferred in new code.

The older typed wrappers

NullString, NullInt64, NullInt32, NullInt16, NullByte, NullFloat64, NullBool, NullTime

Same idea, one type per kind. Still fully supported; Null[T] replaces writing a new one per type.

A pointer

var year *int64

nil means NULL. Convenient when the struct is also serialised to JSON with omitempty.

Make the column NOT NULL or COALESCE in SQL

COALESCE(year, 0) in the select list

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 Stmt is bound to the DB, not to one connection. If the connection it was prepared on is busy or gone, database/sql transparently prepares it again on another connection.

  • Stmt is safe for concurrent use and must be closed (defer stmt.Close()) to release server resources.

  • tx.PrepareContext prepares inside a transaction (closed when the transaction ends), and tx.StmtContext(ctx, stmt) adapts an existing statement to run on the transaction’s connection.

  • database/sql checks the number of arguments against the driver’s NumInput and reports sql: expected 2 arguments, got 1 before 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"]

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

LevelDefault

The driver or database default. The zero value.

LevelReadUncommitted, LevelReadCommitted, LevelWriteCommitted

Read committed is the default in most servers; WriteCommitted is rarely supported.

LevelRepeatableRead, LevelSnapshot

Snapshot-style reads; names and exact guarantees differ by database.

LevelSerializable, LevelLinearizable

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

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.DeadlineExceeded or context.Canceled, matched with errors.Is). Whether the server stops work is up to the driver and database.

  • The context passed to BeginTx governs the whole transaction: when it is cancelled, database/sql rolls the transaction back, and later calls on it fail. Commit returns the context’s error if it runs first, or ErrTxDone once the rollback has happened.

  • The context passed to QueryContext also governs iteration: cancelling it makes Rows.Next return false and Rows.Err return 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)

(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 includes LIKE patterns: bind "%" + term + "%" as the argument, but remember that % and _ inside term are 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 orderBy does below. Quoting functions exist in some drivers, but an allow-list is simpler and safer.

  • IN lists 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 ISBN type 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 with govulncheck; 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

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

driver.Driver

Open(name string) (Conn, error). Registered with sql.Register.

driver.DriverContext

Optional. OpenConnector(name) parses the DSN once and returns a Connector, so sql.Open can validate it early.

driver.Connector

Connect(ctx) creates a connection; Driver() returns the driver. Passed to sql.OpenDB.

driver.Conn

Prepare, Close, Begin: one session. Used by one goroutine at a time, so it needs no locking of its own.

driver.ConnBeginTx, ConnPrepareContext

Optional context-aware versions that also receive TxOptions.

driver.QueryerContext, ExecerContext

Optional fast paths that skip the prepare step. A driver returns driver.ErrSkip to fall back to prepare-and-execute.

driver.Stmt, StmtQueryContext, StmtExecContext

A prepared statement; NumInput lets database/sql check the argument count.

driver.Rows

Columns, Close, Next(dest []driver.Value); optional interfaces report column types and further result sets.

driver.Pinger, Validator, SessionResetter

Optional. Health checks, "is this connection still usable" before reuse, and cleanup when a connection returns to the pool.

driver.NamedValueChecker, Valuer

Control how arguments are converted, including sql.Named and driver-specific types.

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>

Testing database code

Three techniques, in rising cost and fidelity:

  • Hide the database behind an interface defined where it is used (a Store interface like the one in the shelf catalog package, 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 DB per request, or calling Open in a handler. Create it once.

  • Not closing Rows, Stmt, Tx or Conn on every path, including errors. Use defer straight 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 Query for statements that return no rows (it leaks a connection until Close), or Exec for SELECT.

  • Using db inside a function that was handed a tx: the statement runs outside the transaction.

  • Holding a transaction open across slow work (HTTP calls, user input).

  • Concatenating input into SQL text.

  • Ignoring RowsAffected after an update that must change exactly one row.

  • Comparing errors with == instead of errors.Is / errors.As; the driver error is usually wrapped.

  • Relying on LastInsertId with a driver that does not support it.

  • Logging DSNs or full statements with arguments (credentials and personal data).

  • Reading sql.RawBytes after the next Next call.

  • 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:

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), TxOptions and isolation levels, sql.Named, Rows.ColumnTypes and NextResultSet.

  • Go 1.9: sql.Conn and DB.Conn, sql.Out. Go 1.10: sql.OpenDB and driver.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 Rows natural (iter.Seq2).

  • Go 1.27: sql.ConvertAssign, driver.RowsColumnScanner and driver.ScanContext (the release notes list only these two additions); the Rows.Scan documentation lists uuid.UUID destinations alongside the new standard uuid package. Examples were checked with go 1.27 in go.mod.

  • Go 1.28 (expected February 2027, not released): the sqlrowserr vet analyzer, per its development release notes.