1. Connection Pools & database/sql
database/sql is a generic interface — it works with any database that
has a driver implementing it (Postgres, MySQL, SQLite). sql.Open
doesn't actually connect immediately; it configures a connection pool that connects
lazily, on first real use.
go get github.com/lib/pq # a Postgres driver
db, err := sql.Open("postgres", "postgres://user:pass@localhost/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
if err := db.Ping(); err != nil { // this is what actually connects
log.Fatal(err)
}
db.Ping() is the idiomatic way to fail fast at startup if the database
is unreachable, rather than discovering it on the first real request.
2. Scanning Rows into Structs & sql.ErrNoRows
func GetTask(db *sql.DB, id int) (*Task, error) {
var t Task
err := db.QueryRow(
"SELECT id, title, done FROM tasks WHERE id = $1", id,
).Scan(&t.ID, &t.Title, &t.Done)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("task %d: %w", id, ErrNotFound)
}
if err != nil {
return nil, fmt.Errorf("querying task %d: %w", id, err)
}
return &t, nil
}
sql.ErrNoRows isn't a real error condition — it's how
database/sql reports "the query succeeded, it just matched nothing,"
which every call site needs to check for explicitly (using Week 13's
errors.Is) and translate into whatever the application's own
not-found semantics are, exactly like ErrNotFound above feeding
directly into Week 10's 404 handling.
func ListTasks(db *sql.DB) ([]Task, error) {
rows, err := db.Query("SELECT id, title, done FROM tasks ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close() // always — a leaked rows.Rows leaks a connection
var tasks []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.Title, &t.Done); err != nil {
return nil, err
}
tasks = append(tasks, t)
}
return tasks, rows.Err() // check for an error that occurred during iteration
}
3. Prepared Statements
Every query above already uses $1/parameter placeholders rather than
building SQL with string concatenation — this isn't just style, it's the difference
between safe and SQL-injectable code.
// DANGEROUS — never build SQL by concatenating user input
query := "SELECT * FROM tasks WHERE title = '" + userInput + "'"
db.QueryRow("SELECT * FROM tasks WHERE title = $1", userInput)
Every db.Query/QueryRow/Exec call with
placeholders is already a prepared statement under the hood, sent as parameters
separately from the query text — the database engine itself, not string
interpolation, is what safely substitutes the value, which is exactly what makes
user-controlled input impossible to misinterpret as SQL syntax.
4. Schema Migrations
A schema needs to evolve alongside code, in a way every environment (a teammate's laptop, staging, production) can apply identically and in order — hand-run SQL scripts don't scale past a single developer.
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
migrate create -ext sql -dir migrations -seq add_tasks_table
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT FALSE
);
DROP TABLE tasks;
migrate -database "postgres://user:pass@localhost/mydb?sslmode=disable" -path migrations up
Every migration ships as a numbered pair — up to apply it,
down to reverse it — committed to the repository alongside the code
that depends on it, so the schema's history is versioned exactly like everything
else Week 8 already put under go.mod.
5. Hands-on Exercise
Replace the in-memory store with a real database
Migrate the task API from Weeks 10–11 onto Postgres (or SQLite, if you'd rather avoid running a Postgres server locally).
Requirements:
- A migration creating a
taskstable, with both anupand adownfile, applied viagolang-migrate. - A
Storeinterface (per Week 9's mocking pattern) withdatabase/sql-backed methods for list, get-by-id, create, and update — every query using parameter placeholders, never string concatenation. - Correct handling of
sql.ErrNoRowsin the get-by-id method, translated into the same404path Week 11's error handling already established. - A connection pool configured with sensible
SetMaxOpenConns/SetMaxIdleConnsvalues, and adb.Ping()at startup that fails fast if the database is unreachable. - Confirm your Week 9-style table-driven tests still pass against a fake
Storeimplementation, unaffected by the real database now backing production code.
If rows.Next() seems to silently stop early without an obvious error, check that you're calling rows.Err() after the loop, not just checking each individual Scan call's error — an error that terminates iteration itself (a dropped connection mid-scan, for instance) only surfaces through rows.Err(), not through any single Scan call.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does sql.Open succeeding not guarantee the database is actually reachable?
Why does sql.Open succeeding not guarantee the database is actually reachable?
sql.Open only validates its arguments and configures a connection pool — it doesn't establish a real connection until something actually needs one, which could be the very first query in production rather than at startup. Calling db.Ping() explicitly forces that first real connection attempt immediately, so a misconfigured or unreachable database fails loudly at startup instead of on a user's first request.
Q2
What does sql.ErrNoRows actually indicate, and why is checking for it a required, not optional, part of a query function?
What does sql.ErrNoRows actually indicate, and why is checking for it a required, not optional, part of a query function?
It means the query executed successfully but matched zero rows — it's not a failure of the query itself, just an empty result reported through the error return value rather than a normal empty result set (since QueryRow expects exactly one row). Every caller has to check for it explicitly and decide what an empty result means for that specific case — usually translating it into an application-level "not found" rather than treating it as a generic database error.
Q3
Why does using a parameter placeholder like $1 instead of string concatenation prevent SQL injection, specifically?
Why does using a parameter placeholder like $1 instead of string concatenation prevent SQL injection, specifically?
With a placeholder, the query text and the user-supplied value are sent to the database separately — the database parses the query structure first, then substitutes the parameter as pure data, never as part of the SQL syntax itself. String concatenation instead builds the actual query text using untrusted input, so a value containing SQL syntax (like a stray quote followed by more SQL) can change the meaning of the query itself.
Q4
Why commit both an "up" and a "down" migration file for every schema change, rather than just the forward change?
Why commit both an "up" and a "down" migration file for every schema change, rather than just the forward change?
The "down" migration is what makes a schema change safely reversible — if a migration turns out to be broken in a real environment, or a deployment needs to roll back, having a tested, versioned way to undo exactly that change avoids manually reverse-engineering the SQL to undo it under pressure. It also keeps the schema's full history, forward and backward, versioned alongside the code exactly the way go.mod versions dependencies.