database/sql en Go: conectar a BD, queries, Scan, transacciones y pool de conexiones

El paquete database/sql de Go proporciona una interfaz genérica para bases de datos relacionales. El driver específico (PostgreSQL, MySQL, SQLite) se importa como efecto secundario y database/sql se encarga del pool de conexiones, las transacciones y la gestión de recursos.

Abrir la conexión

sql.Open no abre ninguna conexión real: solo valida el DSN y configura el pool. Para verificar que la BD es accesible, usa Ping:

package main

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

    _ "github.com/lib/pq" // driver PostgreSQL, importado por efecto secundario
)

func main() {
    dsn := "host=localhost user=go dbname=tienda password=secreto sslmode=disable"
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        log.Fatal("open:", err)
    }
    defer db.Close()

    // Configurar el pool de conexiones
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(5 * time.Minute)

    if err := db.Ping(); err != nil {
        log.Fatal("ping:", err)
    }
    fmt.Println("conectado a PostgreSQL")
}

QueryContext: leer múltiples filas

Usa siempre las variantes Context para que el timeout del contexto cancele la query si tarda demasiado:

type Producto struct {
    ID     int
    Nombre string
    Precio float64
    Stock  int
}

func listarProductos(ctx context.Context, db *sql.DB) ([]Producto, error) {
    rows, err := db.QueryContext(ctx,
        "SELECT id, nombre, precio, stock FROM productos WHERE activo = true ORDER BY nombre")
    if err != nil {
        return nil, fmt.Errorf("query: %w", err)
    }
    defer rows.Close()

    var productos []Producto
    for rows.Next() {
        var p Producto
        if err := rows.Scan(&p.ID, &p.Nombre, &p.Precio, &p.Stock); err != nil {
            return nil, fmt.Errorf("scan: %w", err)
        }
        productos = append(productos, p)
    }
    return productos, rows.Err()
}

QueryRowContext: leer una sola fila

func obtenerProducto(ctx context.Context, db *sql.DB, id int) (*Producto, error) {
    var p Producto
    err := db.QueryRowContext(ctx,
        "SELECT id, nombre, precio, stock FROM productos WHERE id = $1", id).
        Scan(&p.ID, &p.Nombre, &p.Precio, &p.Stock)

    if err == sql.ErrNoRows {
        return nil, nil // no encontrado, sin error
    }
    if err != nil {
        return nil, fmt.Errorf("obtener producto %d: %w", id, err)
    }
    return &p, nil
}

ExecContext: INSERT, UPDATE y DELETE

func crearProducto(ctx context.Context, db *sql.DB, p Producto) (int64, error) {
    result, err := db.ExecContext(ctx,
        "INSERT INTO productos (nombre, precio, stock) VALUES ($1, $2, $3)",
        p.Nombre, p.Precio, p.Stock)
    if err != nil {
        return 0, fmt.Errorf("insert: %w", err)
    }
    id, err := result.LastInsertId()
    return id, err
}

Transacciones con BeginTx

Las transacciones agrupan varias operaciones en una unidad atómica. Si algo falla, el defer tx.Rollback() deshace todos los cambios:

func transferirStock(ctx context.Context, db *sql.DB, origenID, destinoID, cantidad int) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback() // no hace nada si ya hicimos Commit

    // Reducir stock del origen
    _, err = tx.ExecContext(ctx,
        "UPDATE productos SET stock = stock - $1 WHERE id = $2 AND stock >= $1",
        cantidad, origenID)
    if err != nil {
        return fmt.Errorf("reducir stock origen: %w", err)
    }

    // Aumentar stock del destino
    _, err = tx.ExecContext(ctx,
        "UPDATE productos SET stock = stock + $1 WHERE id = $2",
        cantidad, destinoID)
    if err != nil {
        return fmt.Errorf("aumentar stock destino: %w", err)
    }

    return tx.Commit()
}

Prepared statements

Los prepared statements mejoran el rendimiento cuando ejecutas la misma query muchas veces y protegen contra inyección SQL:

func cargaMasiva(ctx context.Context, db *sql.DB, productos []Producto) error {
    stmt, err := db.PrepareContext(ctx,
        "INSERT INTO productos (nombre, precio, stock) VALUES ($1, $2, $3)")
    if err != nil {
        return err
    }
    defer stmt.Close()

    for _, p := range productos {
        if _, err := stmt.ExecContext(ctx, p.Nombre, p.Precio, p.Stock); err != nil {
            return fmt.Errorf("insertar %s: %w", p.Nombre, err)
        }
    }
    return nil
}

Valores nulos con sql.NullString y sql.NullInt64

type Pedido struct {
    ID         int
    UsuarioID  int
    NotaEspecial sql.NullString // puede ser NULL en la BD
}

func obtenerPedido(ctx context.Context, db *sql.DB, id int) (*Pedido, error) {
    var p Pedido
    err := db.QueryRowContext(ctx,
        "SELECT id, usuario_id, nota_especial FROM pedidos WHERE id = $1", id).
        Scan(&p.ID, &p.UsuarioID, &p.NotaEspecial)
    if err != nil {
        return nil, err
    }
    if p.NotaEspecial.Valid {
        fmt.Println("nota:", p.NotaEspecial.String)
    }
    return &p, nil
}

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP