🔌 Analogy
database/sql is a universal power adapter. Your code plugs into one standard socket — Query, Exec, Scan — and a driver behind it speaks the dialect of whatever database is on the wall (Postgres, MySQL, SQLite). Swap the database and you swap the driver; your code keeps the same plug.
A driver-agnostic layer
database/sql is the standard library’s SQL interface — it knows about pools, statements, rows, and transactions, but it does not know how to talk to any specific database. That job belongs to a third-party driver you import for its side effects (_ "github.com/jackc/pgx/v5/stdlib"), which registers itself by name. Your application code only ever touches database/sql types, so switching databases is mostly a one-line change.
graph LR
APP["your code<br/>Query · Exec · Scan"] --> SQL["database/sql<br/>(pool, rows, tx)"]
SQL --> DRV["driver<br/>(pgx, mysql, sqlite)"]
DRV --> POOL["connection pool"]
POOL --> DB[("database")]sql.Open returns a pool, not a connection
This is the single most misunderstood fact about the package:
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // registers the "pgx" driver
)
// dsn is a placeholder — never commit real credentials
dsn := "postgres://user:pass@localhost:5432/app?sslmode=disable"
db, err := sql.Open("pgx", dsn)
if err != nil {
log.Fatal(err) // only fires for a bad driver name / DSN syntax
}
defer db.Close()
// sql.Open did NOT connect. Verify connectivity explicitly:
if err := db.PingContext(ctx); err != nil {
log.Fatal(err)
}
// Tune the pool for your workload:
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
*sql.DB is a pool of connections that is safe for concurrent use by many goroutines. Open it once at startup and pass it around (or inject it) — do not open a new *sql.DB per request. The pool opens, reuses, and retires real connections for you.
Query, scan, and always close
A single row with QueryRow, many rows with Query, and no rows (INSERT/UPDATE/DELETE) with Exec. Use the …Context variants so a cancelled request cancels the query too:
// One row: QueryRow + Scan. ErrNoRows means "not found".
var name string
var age int
err := db.QueryRowContext(ctx,
"SELECT name, age FROM users WHERE id = $1", id).Scan(&name, &age)
if errors.Is(err, sql.ErrNoRows) {
return ErrUserNotFound
} else if err != nil {
return err
}
// Many rows: Query, then ALWAYS close and check rows.Err().
rows, err := db.QueryContext(ctx,
"SELECT id, name FROM users WHERE age > $1 ORDER BY id", minAge)
if err != nil {
return err
}
defer rows.Close() // returns the connection to the pool
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return err
}
users = append(users, u)
}
if err := rows.Err(); err != nil { // the loop can stop on an ERROR, not just EOF
return err
}
// No rows back: Exec, then inspect the result.
res, err := db.ExecContext(ctx,
"UPDATE users SET age = $1 WHERE id = $2", age, id)
if err != nil {
return err
}
n, _ := res.RowsAffected() // 0 means the WHERE matched nothing
Each placeholder ($1, $2, … for Postgres; ? for MySQL/SQLite) is filled from the trailing arguments. The values travel separately from the SQL text, so a malicious string like '; DROP TABLE users; -- is stored as plain data, never executed.
NULLs and the sql.Null types
A SQL column that can be NULL won’t Scan into a plain string or int. Use the nullable wrappers — sql.NullString, sql.NullInt64, sql.NullTime, and friends (Go 1.22+ also has the generic sql.Null[T]):
var nickname sql.NullString
err := db.QueryRowContext(ctx,
"SELECT nickname FROM users WHERE id = $1", id).Scan(&nickname)
if nickname.Valid {
fmt.Println("nickname:", nickname.String)
} else {
fmt.Println("no nickname set")
}
Reference
| Task | API |
|---|---|
| Create the pool | sql.Open(driver, dsn) → *sql.DB (once, at startup) |
| Verify connectivity | db.PingContext(ctx) |
| Tune the pool | SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime |
| One row | db.QueryRowContext(...).Scan(&a, &b) |
| Not found | errors.Is(err, sql.ErrNoRows) |
| Many rows | db.QueryContext + rows.Next/Scan + rows.Close + rows.Err |
| Write (no rows) | db.ExecContext(...) → RowsAffected() |
| Safe values | placeholders ($1/?), never string-concat |
| Nullable column | sql.NullString / sql.Null[T], check .Valid |
| Reused query | db.PrepareContext → *sql.Stmt |
⚠️ The pool is real, the connection is lazy — and never concatenate SQL
Three traps catch everyone. (1) sql.Open doesn’t connect — a successful Open with an unreachable database returns no error until the first query or Ping. (2) Leaking rows starves the pool: forget rows.Close() and connections never come back, so the app eventually hangs waiting for a free one. Always defer rows.Close(). (3) Never build query text from user input with fmt.Sprintf or + — use placeholders so the driver keeps data and code apart. These are unrunnable here because they need a real driver and database; verify them locally.
See also
- SQL transactions — grouping statements into all-or-nothing units.
- Postgres & Redis — picking a driver and a datastore.
- web security — parameterized queries as injection defense.
- project layout & DI — injecting the
*sql.DBinto your services.
Next: grouping statements that must all succeed or all fail — SQL Transactions.
Related topics
Begin, Commit, Rollback — grouping statements into all-or-nothing units, the defer-rollback safety pattern, ACID, and isolation levels.
apisBuilding REST APIsJSON over HTTP done right — resources and methods, idempotency, decoding/validating requests and encoding responses, the status codes that matter, consistent error envelopes, and versioning.
dataPostgres & RedisChoosing drivers and datastores — pgx for relational/transactional data, go-redis for caching and sessions, and the cache-aside pattern that ties them together.
Check your understanding
Score: 0 / 51. What does sql.Open return?
sql.Open just validates arguments and prepares a *sql.DB pool; it lazily opens connections on first use. Call db.Ping (or PingContext) if you need to verify connectivity at startup, and keep the *sql.DB for the program's lifetime instead of opening one per request.
2. How do you safely include a user-supplied value in a query?
Parameterized queries send the SQL and the values on separate channels, so the driver can never confuse data for code — this is what prevents SQL injection. Concatenating or Sprintf-ing user input into query text is the classic injection hole; placeholders ($1/$2 for Postgres, ? for MySQL/SQLite) are the only safe path.
3. After ranging over the result of db.Query, what must you do?
rows.Next() returning false can mean end-of-data OR an error mid-iteration, so you must check rows.Err() afterward. And you must Close() the rows to return the connection to the pool — defer rows.Close() right after the error check. db.Close() shuts the whole pool and is only for program exit.
4. Why prefer the ...Context query methods (QueryContext, ExecContext) over the plain ones?
Passing the request's context lets a cancelled/timed-out request abort the in-flight query and return its pooled connection, instead of holding it until the DB finishes. Always thread ctx from the handler down to QueryContext/ExecContext/QueryRowContext.
5. How do you Scan a column that might be NULL into a Go value?
Scanning SQL NULL into a plain string/int errors. The sql.Null* types (and the generic sql.Null[T] in Go 1.22+) hold both the value and a Valid bool: if Valid is false the column was NULL. A *string pointer also works (nil = NULL), but the Null types are clearer.
Comments
Sign in with GitHub to join the discussion.