← Back to Index

Chapter 6: Database

database/sql interface, MySQL & transactions

1 database/sql Interface

Go's database/sql package provides a generic interface for SQL databases. You need a separate driver for each database (MySQL, PostgreSQL, SQLite, etc.), but the API remains the same across all drivers.

Connecting to MySQL

package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/go-sql-driver/mysql" // blank import registers the driver
)

func main() {
    // DSN format: user:password@tcp(host:port)/dbname?params
    dsn := "root:password@tcp(127.0.0.1:3306)/mydb?charset=utf8mb4&parseTime=true"

    db, err := sql.Open("mysql", dsn)
    if err != nil {
        log.Fatal("Failed to open database:", err)
    }
    defer db.Close()

    // sql.Open doesn't actually connect β€” Ping verifies connectivity
    if err := db.Ping(); err != nil {
        log.Fatal("Failed to connect:", err)
    }
    fmt.Println("Connected to MySQL!")
}

Key Points

  • β€’ sql.Open creates a connection pool, not a single connection
  • β€’ The blank import _ "github.com/go-sql-driver/mysql" calls the driver's init() to register itself
  • β€’ Always call db.Ping() to verify the connection is alive
  • β€’ db.Close() is rarely needed in practice β€” the pool is meant to be long-lived

πŸ”„ Comparison: Database Interfaces

Go database/sql

Standard library interface with driver plugins. Built-in connection pooling. Manual SQL queries with rows.Scan.

PHP PDO

Built-in abstraction layer. Per-request connections (no pooling without extensions). Prepared statements with execute.

Node.js mysql2

Third-party package. Promise-based API. Optional connection pooling via createPool.

2 CRUD Operations

The three main query methods: db.Query for multiple rows, db.QueryRow for a single row, and db.Exec for inserts, updates, and deletes.

Define the Model

type User struct {
    ID        int
    Name      string
    Email     string
    CreatedAt time.Time
}

INSERT β€” db.Exec

func createUser(db *sql.DB, name, email string) (int64, error) {
    result, err := db.Exec(
        "INSERT INTO users (name, email) VALUES (?, ?)",
        name, email,
    )
    if err != nil {
        return 0, err
    }
    return result.LastInsertId()
}

// Usage
id, err := createUser(db, "Alice", "alice@example.com")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Inserted user with ID: %d\n", id)

SELECT Single Row β€” db.QueryRow

func getUserByID(db *sql.DB, id int) (User, error) {
    var user User
    err := db.QueryRow(
        "SELECT id, name, email, created_at FROM users WHERE id = ?", id,
    ).Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)

    if err == sql.ErrNoRows {
        return user, fmt.Errorf("user %d not found", id)
    }
    return user, err
}

SELECT Multiple Rows β€” db.Query

func listUsers(db *sql.DB) ([]User, error) {
    rows, err := db.Query("SELECT id, name, email, created_at FROM users")
    if err != nil {
        return nil, err
    }
    defer rows.Close() // always close rows!

    var users []User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
            return nil, err
        }
        users = append(users, u)
    }

    // Check for errors from iterating over rows
    if err := rows.Err(); err != nil {
        return nil, err
    }
    return users, nil
}

UPDATE & DELETE β€” db.Exec

func updateUser(db *sql.DB, id int, name string) error {
    result, err := db.Exec("UPDATE users SET name = ? WHERE id = ?", name, id)
    if err != nil {
        return err
    }
    rows, _ := result.RowsAffected()
    if rows == 0 {
        return fmt.Errorf("user %d not found", id)
    }
    return nil
}

func deleteUser(db *sql.DB, id int) error {
    result, err := db.Exec("DELETE FROM users WHERE id = ?", id)
    if err != nil {
        return err
    }
    rows, _ := result.RowsAffected()
    if rows == 0 {
        return fmt.Errorf("user %d not found", id)
    }
    return nil
}

⚠️ Critical Rules

  • β€’ Always defer rows.Close() when using db.Query
  • β€’ Always check rows.Err() after the iteration loop
  • β€’ Use ? placeholders (MySQL) or $1 (PostgreSQL) β€” never concatenate user input
  • β€’ Scan fields must match the SELECT column order exactly

3 Prepared Statements

Prepared statements pre-compile SQL on the server side. They improve performance when executing the same query many times and prevent SQL injection by separating SQL logic from data.

func batchInsertUsers(db *sql.DB, users []User) error {
    stmt, err := db.Prepare("INSERT INTO users (name, email) VALUES (?, ?)")
    if err != nil {
        return err
    }
    defer stmt.Close()

    for _, u := range users {
        _, err := stmt.Exec(u.Name, u.Email)
        if err != nil {
            return fmt.Errorf("failed to insert user %s: %w", u.Name, err)
        }
    }
    return nil
}

func findUsersByAge(db *sql.DB, minAge, maxAge int) ([]User, error) {
    stmt, err := db.Prepare(
        "SELECT id, name, email, created_at FROM users WHERE age BETWEEN ? AND ?",
    )
    if err != nil {
        return nil, err
    }
    defer stmt.Close()

    rows, err := stmt.Query(minAge, maxAge)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
            return nil, err
        }
        users = append(users, u)
    }
    return users, rows.Err()
}

SQL Injection Prevention

❌ Dangerous

query := "SELECT * FROM users WHERE name = '" + name + "'"
db.Query(query)

βœ… Safe

db.Query("SELECT * FROM users WHERE name = ?", name)

4 Transactions

Transactions group multiple operations into an atomic unit β€” either all succeed or all are rolled back. Go uses db.Begin() to start a transaction, then tx.Commit() or tx.Rollback() to finish it.

func transferFunds(db *sql.DB, fromID, toID int, amount float64) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    // defer Rollback is safe β€” it's a no-op after Commit
    defer tx.Rollback()

    // Check sender's balance
    var balance float64
    err = tx.QueryRow("SELECT balance FROM accounts WHERE id = ?", fromID).Scan(&balance)
    if err != nil {
        return fmt.Errorf("query sender: %w", err)
    }
    if balance < amount {
        return fmt.Errorf("insufficient funds: have %.2f, need %.2f", balance, amount)
    }

    // Deduct from sender
    _, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
    if err != nil {
        return fmt.Errorf("deduct from sender: %w", err)
    }

    // Add to receiver
    _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
    if err != nil {
        return fmt.Errorf("add to receiver: %w", err)
    }

    // Log the transfer
    _, err = tx.Exec(
        "INSERT INTO transfers (from_id, to_id, amount) VALUES (?, ?, ?)",
        fromID, toID, amount,
    )
    if err != nil {
        return fmt.Errorf("log transfer: %w", err)
    }

    return tx.Commit()
}

Transaction Pattern

  • β€’ Always defer tx.Rollback() immediately after db.Begin()
  • β€’ Rollback after Commit is a safe no-op
  • β€’ Use tx.Query/tx.Exec (not db.Query/db.Exec) inside transactions
  • β€’ Return tx.Commit() to propagate any commit errors

Generic Transaction Helper

func withTransaction(db *sql.DB, fn func(tx *sql.Tx) error) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    defer tx.Rollback()

    if err := fn(tx); err != nil {
        return err
    }
    return tx.Commit()
}

// Usage
err := withTransaction(db, func(tx *sql.Tx) error {
    _, err := tx.Exec("INSERT INTO orders (user_id, total) VALUES (?, ?)", userID, total)
    if err != nil {
        return err
    }
    _, err = tx.Exec("UPDATE inventory SET stock = stock - ? WHERE product_id = ?", qty, productID)
    return err
})

5 Connection Pool & Best Practices

sql.DB manages a pool of connections automatically. Proper configuration prevents resource exhaustion and stale connections.

func setupDB(dsn string) (*sql.DB, error) {
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        return nil, err
    }

    // Maximum number of open connections (default: unlimited)
    db.SetMaxOpenConns(25)

    // Maximum number of idle connections in the pool
    db.SetMaxIdleConns(10)

    // Maximum lifetime of a connection (recycle stale connections)
    db.SetConnMaxLifetime(5 * time.Minute)

    // Maximum time a connection can be idle before being closed
    db.SetConnMaxIdleTime(3 * time.Minute)

    if err := db.Ping(); err != nil {
        return nil, err
    }
    return db, nil
}

Pool Configuration Guidelines

Setting Recommended Purpose
SetMaxOpenConns 25–50 Prevents overwhelming the DB server
SetMaxIdleConns 10–25 Keeps warm connections ready; ≀ MaxOpenConns
SetConnMaxLifetime 5 min Recycles connections; should be < DB wait_timeout
SetConnMaxIdleTime 3 min Closes idle connections to free resources

πŸ’‘ Best Practices

  • β€’ Create one *sql.DB instance and share it across your application
  • β€’ Use context-aware methods (QueryContext, ExecContext) for timeouts and cancellation
  • β€’ Handle sql.ErrNoRows explicitly β€” it's not always a real error
  • β€’ Use %w in fmt.Errorf to wrap errors for easier debugging

6 Chapter Summary

πŸ”Œ

database/sql

Standard interface, driver plugins, sql.Open, Ping

πŸ“

CRUD

Query, QueryRow, Exec, rows.Scan pattern

πŸ›‘οΈ

Prepared Statements

db.Prepare, SQL injection prevention, batch operations

πŸ”„

Transactions

Begin/Commit/Rollback, defer pattern, atomic ops

🏊

Connection Pool

MaxOpenConns, MaxIdleConns, ConnMaxLifetime

βœ…

Best Practices

Single DB instance, context-aware queries, error wrapping