🗄️ 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.
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.OpenNO 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 usadb.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.Querytoma una conexión del pool, la usa y la devuelve. Las conexiones se comparten entre goroutines de forma segura:*sql.DBes 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). Dasql.ResultconLastInsertId()yRowsAffected().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.Rowsesté abierto mantiene ocupada una conexión del pool. Usadefer rows.Close()justo después de comprobarerr. Y revisarows.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 delCommit(por unreturntemprano o unpanic), eldeferejecuta el Rollback y la transacción no queda colgada. ElCommitexplí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.ErrNoRowsde los demás errores. El patrón correcto es: comparar primero consql.ErrNoRows(caso esperado), y todo lo demás tratarlo como fallo real y propagarlo. Si usaserrors.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.sqldeclaras el estado deseado y listo. Integración nativa en Go víaariga.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 condb.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
- Documentación oficial de database/sql: la referencia canónica.
- Go Database/SQL Tutorial: guía oficial del equipo de Go.
- pgx — driver moderno para PostgreSQL: más rápido que lib/pq, con modo
pgxpool. - sqlc — genera código type-safe desde SQL: el estándar moderno.
- GORM — el ORM de Go: documentación completa del ORM.
- golang-migrate: migraciones versionadas en línea de comandos.
- Ruta completa: Backend con Go.