Week 12: Working with Databases: database/sql & Migrations

The in-memory store from Weeks 10–11 was always a placeholder. This week replaces it with a real database, through Go's standard database/sql interface — the same abstraction almost every Go service, regardless of which specific database it targets, is actually built on.

Module 12 of 16 Week 12 of 16 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Open and use a connection pool through database/sql
  • Scan query results into structs, and handle sql.ErrNoRows correctly
  • Use prepared statements safely instead of building SQL by string concatenation
  • Version a database schema with migrations kept alongside application code

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.

terminal
go get github.com/lib/pq  # a Postgres driver
opening a pool
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

a single-row query
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.

scanning multiple rows
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.

never do this
// DANGEROUS — never build SQL by concatenating user input
query := "SELECT * FROM tasks WHERE title = '" + userInput + "'"
always do this
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.

terminal — golang-migrate
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
migrate create -ext sql -dir migrations -seq add_tasks_table
migrations/000001_add_tasks_table.up.sql
CREATE TABLE tasks (
    id    SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    done  BOOLEAN NOT NULL DEFAULT FALSE
);
migrations/000001_add_tasks_table.down.sql
DROP TABLE tasks;
terminal — applying migrations
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

Hands-on

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:

  1. A migration creating a tasks table, with both an up and a down file, applied via golang-migrate.
  2. A Store interface (per Week 9's mocking pattern) with database/sql-backed methods for list, get-by-id, create, and update — every query using parameter placeholders, never string concatenation.
  3. Correct handling of sql.ErrNoRows in the get-by-id method, translated into the same 404 path Week 11's error handling already established.
  4. A connection pool configured with sensible SetMaxOpenConns/SetMaxIdleConns values, and a db.Ping() at startup that fails fast if the database is unreachable.
  5. Confirm your Week 9-style table-driven tests still pass against a fake Store implementation, unaffected by the real database now backing production code.
Hint

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?

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?

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?

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?

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.