🔎 Buscar

🗄️ Bases de datos en Go

database/sql, connection pools, prepared statements, transacciones, migrations y ORMs: cómo hablar con PostgreSQL desde Go como un profesional.

Wiki / Apuntes📖 Contenido

Bases de datos en Go

Casi todo backend termina hablando con una base de datos. En Go la capa estándar es database/sql: una interfaz común sobre SQL que deja el driver concreto a librerías como lib/pq o pgx. No hay «magia» — tú escribes SQL, y el paquete se encarga de pool, conexiones y escaneo. Entender esta capa te da control total antes de saltar a ORMs como GORM.

database/sql: cómo funciona

database/sql es una capa de abstracción: te da un *sql.DB (un pool de conexiones), pero no implementa ningún protocolo. El driver real (PostgreSQL, MySQL, SQLite…) se registra por nombre y habla con el servidor por debajo.

import (
	"database/sql"
	_ "github.com/lib/pq" // driver PostgreSQL (side-effect: registra "postgres")
)

func main() {
	dsn := "postgres://usuario:secreto@localhost:5432/mi_db?sslmode=disable"
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		panic(err)
	}
	defer db.Close()
}

⚠️ sql.Open NO conecta. Solo crea el pool y valida el DSN. La primera conexión real se abre con el primer query. Para verificar que hay conexión usa db.Ping().

if err := db.Ping(); err != nil {
	log.Fatalf("no hay conexión: %v", err)
}

El import _ "github.com/lib/pq" registra el driver mediante su init() — un efecto secundario intencionado. Nunca uses el paquete directamente; siempre a través de *sql.DB.

El connection pool: configura los 3 ajustes

El valor cero de *sql.DB es un pool sin límites, que es una bomba de reloj: picos de tráfico crean cientos de conexiones y la base de datos se cae. Configura siempre estos tres:

db.SetMaxOpenConns(25)             // máx. conexiones abiertas a la vez
db.SetMaxIdleConns(25)             // máx. conexiones inactivas en cola
db.SetConnMaxLifetime(5 * time.Minute) // recicla conexiones viejas
db.SetConnMaxIdleTime(2 * time.Minute) // descarta idle antiguas
Método Qué hace Regla práctica
SetMaxOpenConns Tope de conexiones simultáneas ≈ nº de CPUs del servidor × 2–4
SetMaxIdleConns Conexiones que reutilizar sin reconectar MaxOpenConns
SetConnMaxLifetime Tiempo de vida máxima de cada conexión menor que el wait_timeout del servidor

💡 Reutilizar conexiones es lo que te da rendimiento. Cada db.Query toma una conexión del pool, la usa y la devuelve. Las conexiones se comparten entre goroutines de forma segura: *sql.DB es concurrente.

QueryRow, Query y Exec

Tres formas de hablar con la base, según lo que esperas devolver:

  • db.Exec — no devuelve filas (INSERT, UPDATE, DELETE, DDL). Da sql.Result con LastInsertId() y RowsAffected().
  • db.Query — devuelve varias filas (*sql.Rows).
  • db.QueryRow — devuelve una sola fila (*sql.Row).
// Exec: INSERT sin filas de retorno
res, err := db.Exec("INSERT INTO usuarios (nombre) VALUES ($1)", "ana")
if err != nil {
	log.Fatal(err)
}
fmt.Println("afectadas:", res.RowsAffected())

// QueryRow: una fila, un usuario
var nombre string
err = db.QueryRow("SELECT nombre FROM usuarios WHERE id = $1", 7).Scan(&nombre)
if err != nil {
	log.Fatal(err)
}
fmt.Println(nombre)

// Query: muchas filas
rows, err := db.Query("SELECT id, nombre FROM usuarios ORDER BY id")
if err != nil {
	log.Fatal(err)
}
defer rows.Close() // siempre: libera la conexión
for rows.Next() {
	var id int
	var n string
	if err := rows.Scan(&id, &n); err != nil {
		log.Fatal(err)
	}
	fmt.Println(id, n)
}
if err := rows.Err(); err != nil { // error tras iterar
	log.Fatal(err)
}

⚠️ Nunca olvides rows.Close(). Mientras el *sql.Rows esté abierto mantiene ocupada una conexión del pool. Usa defer rows.Close() justo después de comprobar err. Y revisa rows.Err() al terminar de iterar: errores que ocurren durante la lectura solo aparecen ahí.

Los placeholders $1, $2, … son de PostgreSQL. En MySQL serían ?. El driver se encarga de escapar los parámetros, así que nunca concatenes valores en el SQL (inyección SQL).

Prepared statements

Cuando ejecutas el mismo SQL muchas veces con parámetros distintos, prepara el statement una vez y reutilízalo: db.Prepare("INSERT INTO productos (nombre, precio) VALUES ($1, $2)") devuelve un *sql.Stmt que ejecutas tantas veces como quieras con stmt.Exec(p.nombre, p.precio) (no olvides defer stmt.Close()).

El servidor compila el plan de ejecución una vez y luego solo cambia los parámetros. Para inserts masivos es notablemente más rápido que mandar N queries completos. Alternativa aún más rápida para volúmenes grandes: COPY FROM (específico de PostgreSQL, a través de pgx.CopyFrom).

Transacciones: todo o nada

Una transacción agrupa varias operaciones: si una falla, se deshacen todas (Rollback). En Go, tx.Begin() toma una conexión del pool y la reserva solo para la transacción hasta que hagas Commit o Rollback.

func transferir(db *sql.DB, de, a, cantidad int) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	// Rollback automático si algo falla antes del Commit
	defer tx.Rollback()

	var saldo int
	if err := tx.QueryRow(
		"SELECT saldo FROM cuentas WHERE id = $1 FOR UPDATE", de,
	).Scan(&saldo); err != nil {
		return err
	}
	if saldo < cantidad {
		return fmt.Errorf("saldo insuficiente en %d", de)
	}

	if _, err := tx.Exec(
		"UPDATE cuentas SET saldo = saldo - $1 WHERE id = $2", cantidad, de,
	); err != nil {
		return err
	}
	if _, err := tx.Exec(
		"UPDATE cuentas SET saldo = saldo + $1 WHERE id = $2", cantidad, a,
	); err != nil {
		return err
	}
	return tx.Commit() // si llegamos aquí, se confirma
}

💡 defer tx.Rollback() es tu red de seguridad. Si la función retorna antes del Commit (por un return temprano o un panic), el defer ejecuta el Rollback y la transacción no queda colgada. El Commit explícito al final marca que todo salió bien. Un Rollback tras un Commit es un no-op inofensivo.

Escaneo de filas y tipos

Scan asigna cada columna a un puntero. Los tipos nativos de Go se mapean directamente, pero los NULL de la base no caben en int o string: ahí entran sql.NullInt64, sql.NullString, sql.NullTime:

var email sql.NullString // puede ser NULL
err := db.QueryRow(
	"SELECT email FROM usuarios WHERE id = $1", 7,
).Scan(&email)
if err != nil {
	log.Fatal(err)
}
if email.Valid {
	fmt.Println("email:", email.String)
} else {
	fmt.Println("sin email registrado")
}

Cada Null* tiene un campo Valid (¿es NULL?) y el valor real. Escanear un NULL sobre un string plano lanza error converting NULL to string is unsupported — de ahí la necesidad.

Errores: sql.ErrNoRows

El clásico: QueryRow cuando no hay filas. No es un error fatal — es la forma normal de detectar «no existe»:

var nombre string
err := db.QueryRow("SELECT nombre FROM usuarios WHERE id = $1", 999).Scan(&nombre)
if err == sql.ErrNoRows {
	fmt.Println("usuario no encontrado")
	return
}
if err != nil {
	log.Fatal(err) // error real de base de datos
}

⚠️ Distinguir sql.ErrNoRows de los demás errores. El patrón correcto es: comparar primero con sql.ErrNoRows (caso esperado), y todo lo demás tratarlo como fallo real y propagarlo. Si usas errors.Is(err, sql.ErrNoRows) funciona también con errores envueltos.

context: el timeout salva tu API

Toda operación de base de datos acepta un primer argumento ctx. Sin él, un query colgado bloquea la conexión para siempre. Pásale siempre un contexto con timeout:

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

var nombre string
err := db.QueryRowContext(ctx,
	"SELECT nombre FROM usuarios WHERE id = $1", 7,
).Scan(&nombre)
if err == context.DeadlineExceeded {
	log.Println("la base tardó demasiado")
} else if err != nil {
	log.Println(err)
}

En un handler HTTP, usa el r.Context() del request: así un cliente que se desconecta cancela también el query de base de datos. Todas las variantes llevan el sufijo: QueryContext, ExecContext, QueryRowContext, BeginTx(ctx, ...).

Migrations con golang-migrate

Cambiar el esquema de forma controlada y reproducible. golang-migrate usa archivos numerados: uno hacia arriba (_up.sql) y uno hacia abajo (_down.sql).

-- 000001_create_usuarios.up.sql
CREATE TABLE usuarios (
    id           BIGSERIAL PRIMARY KEY,
    nombre       TEXT NOT NULL,
    email        TEXT UNIQUE,
    ultimo_login TIMESTAMPTZ,
    creado_en    TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 000001_create_usuarios.down.sql
DROP TABLE usuarios;
# instalar la CLI
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

# aplicar
migrate -path ./migrations -database "postgres://usuario:secreto@localhost:5432/mi_db" up

# deshacer la última
migrate -path ./migrations -database "postgres://usuario:secreto@localhost:5432/mi_db" down 1

💡 Alternativa moderna: Atlas. ariga.io/atlas funciona con «declarative migrations»: describes el esquema final y Atlas calcula el diff y genera la migración. Con atlas schema apply -u "postgres://…" --to file://schema.sql declaras el estado deseado y listo. Integración nativa en Go vía ariga.io/atlas-provider-gorm.

sqlc: SQL type-safe sin ORM

s sqlc genera código Go a partir de tus queries SQL. Escribes SQL normal y obtienes funciones tipadas — sin ORM, sin reflección, sin interface{}:

go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest

Define queries en queries.sql con un nombre y una cardinalidad (:one, :many):

-- name: GetUsuario :one
SELECT * FROM usuarios WHERE id = $1;

-- name: ListUsuarios :many
SELECT id, nombre FROM usuarios ORDER BY nombre;

-- name: CrearUsuario :one
INSERT INTO usuarios (nombre, email) VALUES ($1, $2) RETURNING id;

Ejecuta sqlc generate y el código generado se usa así:

q := queries.New(db)
u, err := q.GetUsuario(ctx, 7) // -> (Usuario, error)
users, err := q.ListUsuarios(ctx) // -> ([]Usuario, error)
id, err := q.CrearUsuario(ctx, queries.CrearUsuarioParams{Nombre: "ana"})

💡 sqlc detecta errores en tiempo de compilación, no en producción. Un typo en el SQL o un tipo equivocado falla al generar, antes de desplegar. Es la opción favorita del ecosistema moderno: rendimiento de SQL puro con la seguridad de tipos.

GORM: el ORM cuando te conviene

Para prototipos rápidos, CRUD estándar o equipos que vienen de Rails/Django. Mapeas structs a tablas y dejas que GORM escriba el SQL:

import (
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

type Usuario struct {
	ID     uint `gorm:"primaryKey"`
	Nombre string
	Email  string `gorm:"uniqueIndex"`
}

func main() {
	db, err := gorm.Open(postgres.Open(
		"host=localhost user=usuario password=secreto dbname=mi_db port=5432 sslmode=disable",
	), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}

	db.AutoMigrate(&Usuario{}) // crea/actualiza tablas

	// crear
	db.Create(&Usuario{Nombre: "ana", Email: "ana@x.com"})

	// buscar
	var u Usuario
	db.First(&u, "email = ?", "ana@x.com")

	// actualizar
	db.Model(&u).Update("Nombre", "Ana María")

	// eliminar
	db.Delete(&u)
}

GORM vs SQL directo: cuándo

Criterio SQL directo (database/sql o sqlc) GORM
Rendimiento Máximo Suficiente, con overhead
Consultas complejas Control total Se vuelve confuso
Velocidad de desarrollo Menor Mayor
Safety de tipos Con sqlc sí Parcial
Curva de aprendizaje Baja (solo SQL) Media (DSL propio)

⚠️ GORM no es gratis. Genera SQL dinámicamente (reflección), con coste en rendimiento y en opacidad: cuando algo va lento, tienes que leer el SQL que generó. db.Debug() te muestra cada query. Para consultas analíticas o joins raros, cae a SQL crudo con db.Raw(...).

Ejemplo completo: un repositorio con pool y timeout

Todo junto — pool configurado, contexto, transacciones y errores:

package main

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

	_ "github.com/lib/pq"
)

type Repo struct {
	db *sql.DB
}

func NewRepo(dsn string) (*Repo, error) {
	db, err := sql.Open("postgres", dsn)
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(25)
	db.SetConnMaxLifetime(5 * time.Minute)
	db.SetConnMaxIdleTime(2 * time.Minute)
	if err := db.Ping(); err != nil {
		return nil, err
	}
	return &Repo{db: db}, nil
}

func (r *Repo) CrearUsuario(ctx context.Context, nombre, email string) (int64, error) {
	var id int64
	err := r.db.QueryRowContext(ctx,
		"INSERT INTO usuarios (nombre, email) VALUES ($1, $2) RETURNING id",
		nombre, email,
	).Scan(&id)
	return id, err
}

func main() {
	r, err := NewRepo("postgres://usuario:secreto@localhost:5432/mi_db?sslmode=disable")
	if err != nil {
		log.Fatal(err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	id, err := r.CrearUsuario(ctx, "ana", "ana@x.com")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("usuario creado, id:", id)
}

Cheatsheet

Quieres… Usa…
Abrir pool sql.Open(driver, dsn) + db.Ping()
Limitar conexiones SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime
INSERT / UPDATE db.Exec
Varias filas db.Query + rows.Next() + rows.Close()
Una fila db.QueryRow(...).Scan(&...)
«No existe» err == sql.ErrNoRows
NULL sql.NullString / sql.NullInt64 / sql.NullTime
Todo o nada tx := db.Begin() + Commit / Rollback
Timeout ctx con context.WithTimeout en cada query
Migraciones golang-migrate o Atlas
SQL type-safe sqlc
CRUD rápido GORM

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.