database/sql
database/sql
database/sql is the standard-library interface for relational databases. It defines types and functions that every driver implements. The boring default is: open one sql.DB per process, reuse it everywhere, and always close *sql.Rows before moving on. The package is intentionally database-agnostic; this chapter uses modernc.org/sqlite — a pure-Go SQLite driver that requires no C compiler.
Mental model
sql.DB is a connection pool, not a single connection. The pool opens and closes underlying connections automatically based on load. Calls to QueryRow, Query, and Exec borrow a connection, do the work, then return it to the pool.
Three query methods cover almost every case:
| Method | Returns | Use when |
|---|---|---|
db.Exec |
sql.Result |
INSERT / UPDATE / DELETE |
db.QueryRow |
*sql.Row |
exactly one row expected |
db.Query |
*sql.Rows |
zero or more rows |
*sql.Rows holds an open connection until you close it. Forgetting defer rows.Close() exhausts the pool. Forgetting rows.Err() hides scan-loop errors silently.
A prepared statement (db.Prepare) sends the SQL to the database once and re-executes with different arguments. This avoids repeated parsing overhead and prevents SQL injection.
A transaction (db.BeginTx) runs a group of statements atomically. Call tx.Commit() on success and tx.Rollback() on any error.
Worked examples
All examples share this module definition. Place it in a go.mod file in your working directory and run go mod tidy once:
module desk
go 1.27
require modernc.org/sqlite v1.37.1
go mod tidyCase 1: Open, create table, insert, query one row
Save as desk_ticket.go.
// desk_ticket.go
package main
import (
"database/sql"
"fmt"
"log/slog"
"time"
_ "modernc.org/sqlite"
)
type Ticket struct {
ID int64
Subject string
Status string
Created time.Time
}
func main() {
// :memory: creates a private, in-process SQLite database.
// No file is written to disk.
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
slog.Error("open", "err", err)
return
}
defer db.Close()
// Verify the database is reachable.
if err := db.Ping(); err != nil {
slog.Error("ping", "err", err)
return
}
// Create the tickets table.
const ddl = `
CREATE TABLE tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created TEXT NOT NULL
);`
if _, err := db.Exec(ddl); err != nil {
slog.Error("create table", "err", err)
return
}
// Insert one ticket.
created := time.Now().UTC().Format(time.RFC3339)
res, err := db.Exec(
`INSERT INTO tickets (subject, status, created) VALUES (?, ?, ?)`,
"Keyboard missing from desk 4", "open", created,
)
if err != nil {
slog.Error("insert", "err", err)
return
}
id, _ := res.LastInsertId()
fmt.Printf("inserted ticket id=%d\n", id)
// Query one row by primary key.
var t Ticket
var createdStr string
row := db.QueryRow(
`SELECT id, subject, status, created FROM tickets WHERE id = ?`, id,
)
if err := row.Scan(&t.ID, &t.Subject, &t.Status, &createdStr); err != nil {
slog.Error("scan", "err", err)
return
}
t.Created, _ = time.Parse(time.RFC3339, createdStr)
fmt.Printf("ticket #%d: %q status=%s created=%s\n",
t.ID, t.Subject, t.Status, t.Created.Format(time.DateTime))
}Run:
go run desk_ticket.goOutput:
inserted ticket id=1
ticket #1: "Keyboard missing from desk 4" status=open created=2026-09-09 04:47:00
sql.Open validates the driver name and DSN format but does not open a connection. db.Ping() forces the first real connection. The blank import _ "modernc.org/sqlite" registers the driver’s init function with database/sql.
Case 2: Query multiple rows
Save as desk_list.go. This program inserts several tickets and lists only the open ones.
// desk_list.go
package main
import (
"database/sql"
"fmt"
"log/slog"
"time"
_ "modernc.org/sqlite"
)
func mustExec(db *sql.DB, query string, args ...any) {
if _, err := db.Exec(query, args...); err != nil {
slog.Error("exec", "query", query, "err", err)
panic(err)
}
}
func main() {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
slog.Error("open", "err", err)
return
}
defer db.Close()
mustExec(db, `CREATE TABLE tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created TEXT NOT NULL
)`)
now := time.Now().UTC().Format(time.RFC3339)
mustExec(db, `INSERT INTO tickets (subject, status, created) VALUES (?, ?, ?)`,
"Printer jam on floor 2", "open", now)
mustExec(db, `INSERT INTO tickets (subject, status, created) VALUES (?, ?, ?)`,
"Chair broken at desk 7", "open", now)
mustExec(db, `INSERT INTO tickets (subject, status, created) VALUES (?, ?, ?)`,
"Monitor flickering", "closed", now)
mustExec(db, `INSERT INTO tickets (subject, status, created) VALUES (?, ?, ?)`,
"Coffee machine fault", "open", now)
// Query multiple rows. Defer rows.Close immediately after checking err.
rows, err := db.Query(
`SELECT id, subject FROM tickets WHERE status = ? ORDER BY id`, "open",
)
if err != nil {
slog.Error("query", "err", err)
return
}
defer rows.Close() // Always. Releases the connection back to the pool.
fmt.Println("open tickets:")
for rows.Next() {
var id int64
var subject string
if err := rows.Scan(&id, &subject); err != nil {
slog.Error("scan", "err", err)
return
}
fmt.Printf(" #%d %s\n", id, subject)
}
// Check for errors that occurred during iteration.
// rows.Next() swallows iteration errors; rows.Err() surfaces them.
if err := rows.Err(); err != nil {
slog.Error("rows iteration", "err", err)
}
}Run:
go run desk_list.goOutput:
open tickets:
#1 Printer jam on floor 2
#2 Chair broken at desk 7
#4 Coffee machine fault
The pattern is always: rows, err := db.Query(...) → check err → defer rows.Close() → for rows.Next() → rows.Scan(...) → check rows.Err(). Skipping rows.Err() means a network hiccup or cursor error will silently produce a truncated result set.
Case 3: Prepared statement for repeated inserts
A prepared statement sends the SQL template to the database once. Subsequent calls pass only the argument values. This avoids re-parsing the same SQL on every execution — important when inserting hundreds or thousands of rows.
Save as desk_bulk.go.
// desk_bulk.go
package main
import (
"database/sql"
"fmt"
"log/slog"
"time"
_ "modernc.org/sqlite"
)
func main() {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
slog.Error("open", "err", err)
return
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created TEXT NOT NULL
)`); err != nil {
slog.Error("create table", "err", err)
return
}
// Prepare parses the SQL once. stmt is reused for every insert below.
stmt, err := db.Prepare(
`INSERT INTO tickets (subject, status, created) VALUES (?, ?, ?)`,
)
if err != nil {
slog.Error("prepare", "err", err)
return
}
defer stmt.Close() // Return statement resources when done.
now := time.Now().UTC().Format(time.RFC3339)
subjects := []string{
"Desk lamp out",
"Broken locker key",
"Wi-Fi drops in meeting room A",
"Whiteboard marker missing",
"Reception phone faulty",
}
// Insert 100 tickets using the same prepared statement.
// The SQL is parsed only once regardless of how many rows we insert.
for i := range 100 {
subject := subjects[i%len(subjects)]
if _, err := stmt.Exec(subject, "open", now); err != nil {
slog.Error("stmt exec", "i", i, "err", err)
return
}
}
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM tickets`).Scan(&count); err != nil {
slog.Error("count", "err", err)
return
}
fmt.Printf("inserted %d tickets via prepared statement\n", count)
}Run:
go run desk_bulk.goOutput:
inserted 100 tickets via prepared statement
With an ad-hoc db.Exec inside the loop, the database re-parses the SQL string every iteration. With db.Prepare, parsing happens once. For a remote database such as PostgreSQL, this difference is measurable at hundreds of rows. For SQLite it is smaller but the habit remains correct.
Case 4: Transaction — close a ticket atomically
A transaction bundles multiple statements into one atomic unit. Either every statement commits, or none of them do. Use db.BeginTx (not the older db.Begin) to attach a context.Context for cancellation.
Save as desk_close.go.
// desk_close.go
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
_ "modernc.org/sqlite"
)
func seed(db *sql.DB) (openID, closedID int64, err error) {
now := time.Now().UTC().Format(time.RFC3339)
r1, err := db.Exec(
`INSERT INTO tickets (subject, status, created) VALUES (?, ?, ?)`,
"Standing desk motor broken", "open", now,
)
if err != nil {
return 0, 0, err
}
r2, err := db.Exec(
`INSERT INTO tickets (subject, status, created) VALUES (?, ?, ?)`,
"Headset cable frayed", "closed", now,
)
if err != nil {
return 0, 0, err
}
id1, _ := r1.LastInsertId()
id2, _ := r2.LastInsertId()
return id1, id2, nil
}
// closeTicket moves a ticket to closed and records the resolver.
// It returns an error if the ticket does not exist or is already closed.
func closeTicket(ctx context.Context, db *sql.DB, id int64, resolver string) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
// Rollback is a no-op once Commit has been called.
// Defer ensures resources are released on every return path.
defer tx.Rollback()
// Lock the row and check its current status.
var status string
err = tx.QueryRowContext(ctx,
`SELECT status FROM tickets WHERE id = ?`, id,
).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("ticket %d not found", id)
}
if err != nil {
return fmt.Errorf("select status: %w", err)
}
if status == "closed" {
return fmt.Errorf("ticket %d is already closed", id)
}
// Update status inside the same transaction.
_, err = tx.ExecContext(ctx,
`UPDATE tickets SET status = 'closed' WHERE id = ?`, id,
)
if err != nil {
return fmt.Errorf("update: %w", err)
}
// Record the resolver in a separate audit row.
_, err = tx.ExecContext(ctx,
`INSERT INTO audit (ticket_id, action, actor, ts) VALUES (?, 'closed', ?, ?)`,
id, resolver, time.Now().UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("audit insert: %w", err)
}
// Commit makes both the UPDATE and INSERT permanent atomically.
return tx.Commit()
}
func main() {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
slog.Error("open", "err", err)
return
}
defer db.Close()
for _, ddl := range []string{
`CREATE TABLE tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subject TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
created TEXT NOT NULL
)`,
`CREATE TABLE audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
action TEXT NOT NULL,
actor TEXT NOT NULL,
ts TEXT NOT NULL
)`,
} {
if _, err := db.Exec(ddl); err != nil {
slog.Error("ddl", "err", err)
return
}
}
openID, closedID, err := seed(db)
if err != nil {
slog.Error("seed", "err", err)
return
}
ctx := context.Background()
// Success: close an open ticket.
if err := closeTicket(ctx, db, openID, "alice"); err != nil {
slog.Error("close open ticket", "err", err)
} else {
fmt.Printf("ticket #%d closed successfully\n", openID)
}
// Error path: attempt to close an already-closed ticket.
if err := closeTicket(ctx, db, closedID, "bob"); err != nil {
fmt.Printf("expected error: %v\n", err)
}
// Verify audit table has exactly one row.
var auditCount int
db.QueryRow(`SELECT COUNT(*) FROM audit`).Scan(&auditCount)
fmt.Printf("audit rows: %d\n", auditCount)
}Run:
go run desk_close.goOutput:
ticket #1 closed successfully
expected error: ticket #2 is already closed
audit rows: 1
The deferred tx.Rollback() after a successful tx.Commit() is a no-op. It exists so that any early return — from a failed SELECT, UPDATE, or audit INSERT — automatically undoes partial work. This is the standard Go transaction pattern.
The trap
Forgetting defer rows.Close() leaks a connection for every un-closed *sql.Rows. The pool has a finite number of connections (db.SetMaxOpenConns). Each leaked *sql.Rows pins one connection until the garbage collector finalises it — which may never happen in a tight loop. db.Stats().WaitCount rises as callers queue waiting for a free connection.
Save as desk_leak.go.
// desk_leak.go
package main
import (
"database/sql"
"fmt"
"log/slog"
_ "modernc.org/sqlite"
)
func leakyQuery(db *sql.DB) {
// BUG: rows is never closed.
// The connection is held until the GC collects the *sql.Rows value,
// which may be arbitrarily late.
rows, err := db.Query(`SELECT id FROM tickets LIMIT 1`)
if err != nil {
slog.Error("query", "err", err)
return
}
if rows.Next() {
var id int64
rows.Scan(&id) // result discarded intentionally to focus on the leak
}
// Missing: defer rows.Close()
}
func fixedQuery(db *sql.DB) {
rows, err := db.Query(`SELECT id FROM tickets LIMIT 1`)
if err != nil {
slog.Error("query", "err", err)
return
}
defer rows.Close() // The fix. Always the first line after the error check.
if rows.Next() {
var id int64
rows.Scan(&id)
}
_ = rows.Err()
}
func main() {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
slog.Error("open", "err", err)
return
}
defer db.Close()
// Cap the pool at 3 connections to make the symptom visible quickly.
db.SetMaxOpenConns(3)
if _, err := db.Exec(`CREATE TABLE tickets (id INTEGER PRIMARY KEY)`); err != nil {
slog.Error("ddl", "err", err)
return
}
for i := range 5 {
db.Exec(`INSERT INTO tickets VALUES (?)`, i+1)
}
// Call the leaky version several times.
for range 10 {
leakyQuery(db)
}
fmt.Printf("after leaky queries — open conns: %d wait count: %d\n",
db.Stats().OpenConnections, db.Stats().WaitCount)
// Call the fixed version the same number of times.
for range 10 {
fixedQuery(db)
}
fmt.Printf("after fixed queries — open conns: %d wait count: %d\n",
db.Stats().OpenConnections, db.Stats().WaitCount)
}Run:
go run desk_leak.goOutput:
after leaky queries — open conns: 3 wait count: 7
after fixed queries — open conns: 1 wait count: 7
After the leaky calls the pool is fully occupied and callers had to wait. After the fixed calls the pool returns to a single idle connection. The WaitCount accumulator does not reset between runs — it shows the cumulative damage the leak already caused.
The boring rule
- Always
defer rows.Close()on the line immediately after checking the error fromdb.Query. - Always check
rows.Err()after therows.Next()loop. - Use
db.QueryRowwhen you expect exactly one row. It callsCloseinternally. - Use transactions for any operation that touches more than one row or table.
- Use prepared statements when the same SQL executes in a loop.
- Call
db.SetMaxOpenConnsanddb.SetMaxIdleConnsexplicitly in production; the unlimited default can exhaust database server connection limits. - Register the driver with a blank import (
_ "modernc.org/sqlite") inmainorTestMain, never in library packages.
Try this
desk_list.go: Add a second status filter so the query returns both"open"and"in-progress"tickets. Use a SQLIN (?, ?)clause and pass both values as arguments.desk_bulk.go: Wrap the 100 prepared-statement inserts inside a single transaction. Time the wall-clock difference withtime.Now()before and after the loop. Observe that SQLite is significantly faster when inserts share one transaction instead of auto-committing each row.desk_close.go: Add areopenTicketfunction that mirrorscloseTicket— it checks that the ticket is currently"closed", sets it back to"open", and writes a"reopened"audit row, all in one transaction.desk_leak.go: Removedb.SetMaxOpenConns(3)and re-run. Observe thatWaitCountstays at zero because the unlimited pool never blocks. Add it back and lower the cap to1to see the leak starve all queries.