// 07 · SQL sin miedo (y sin Hibernate)
Bases de datos
La cultura Go prefiere SQL visible sobre ORMs mágicos. Después de años de JPA, esto se siente como quitarse un abrigo mojado.
// la base: database/sql
database/sql + driver
La stdlib trae database/sql — interfaz genérica con pool de conexiones incluido — y cada motor aporta su driver (pgx para Postgres, go-sql-driver/mysql…). Tu HikariCP viene de serie.
import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib" // el _ registra el driver (side effect)
)
func abrirDB(dsn string) (*sql.DB, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25) // pool integrado
db.SetConnMaxIdleTime(5 * time.Minute)
return db, db.Ping()
}
func (r *Repo) PorID(ctx context.Context, id int) (Usuario, error) {
var u Usuario
err := r.db.QueryRowContext(ctx,
`SELECT id, nombre, email FROM usuarios WHERE id = $1`, id,
).Scan(&u.ID, &u.Nombre, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNoEncontrado // traduce el "no hay filas" a TU error de dominio
}
return u, err
}
func (r *Repo) Listar(ctx context.Context) ([]Usuario, error) {
filas, err := r.db.QueryContext(ctx,
`SELECT id, nombre, email FROM usuarios ORDER BY id`)
if err != nil {
return nil, err
}
defer filas.Close() // SIEMPRE: devuelve la conexión al pool
var lista []Usuario
for filas.Next() {
var u Usuario
if err := filas.Scan(&u.ID, &u.Nombre, &u.Email); err != nil {
return nil, err
}
lista = append(lista, u)
}
return lista, filas.Err() // errores que ocurrieron DURANTE la iteración
}Siempre placeholders, jamás concatenar$1, $2 (Postgres) o ? (MySQL) parametrizan la query y eliminan el SQL injection de raíz — tu PreparedStatement de siempre. Y siempre las variantes ...Context: si el request muere, la query se cancela en el servidor de base de datos, no sigue corriendo huérfana.
// de más control a más magia
El espectro de opciones
| Herramienta | Qué es | Equivalente mental |
|---|---|---|
database/sql | Stdlib: SQL crudo + Scan manual | JDBC, pero ergonómico |
sqlx | Stdlib + mapeo automático a structs | Spring JdbcTemplate + RowMapper |
sqlc ★ | Escribes SQL, GENERA el código Go tipado | No existe en Java; lo más "Go" que hay |
GORM | ORM completo: migraciones, hooks, relaciones | Hibernate/JPA; popular pero divide opiniones |
ent | ORM por generación de código (de Meta) | JPA + Criteria API tipado |
sqlx: el punto dulce para empezar
import "github.com/jmoiron/sqlx"
type Usuario struct {
ID int `db:"id"`
Nombre string `db:"nombre"`
Email string `db:"email"`
}
// Adiós al Scan campo por campo: mapea por tags `db:`
var u Usuario
err := db.GetContext(ctx, &u,
`SELECT id, nombre, email FROM usuarios WHERE id = $1`, id)
var todos []Usuario
err = db.SelectContext(ctx, &todos,
`SELECT id, nombre, email FROM usuarios ORDER BY id`)sqlc: SQL primero, Go generado
El flujo que JPA nunca te dio: escribes las queries en un archivo .sql con anotaciones, corres sqlc generate, y obtienes funciones Go tipadas que compilan contra tu esquema real. Si la query tiene un typo o la columna no existe, falla la generación, no producción.
-- name: ObtenerUsuario :one
SELECT id, nombre, email FROM usuarios
WHERE id = $1;
-- name: CrearUsuario :one
INSERT INTO usuarios (nombre, email)
VALUES ($1, $2)
RETURNING id, nombre, email;
-- name: ListarUsuarios :many
SELECT id, nombre, email FROM usuarios
ORDER BY id
LIMIT $1;q := db.New(pool) // el paquete "db" lo escribió sqlc, no tú
u, err := q.ObtenerUsuario(ctx, 42) // tipado: devuelve db.Usuario
nuevo, err := q.CrearUsuario(ctx, db.CrearUsuarioParams{
Nombre: "Ana",
Email: "ana@ejemplo.com",
})
lista, err := q.ListarUsuarios(ctx, 20)Recomendación para tu perfilEmpieza con sqlx (te sentirás en JdbcTemplate) y prueba sqlc en el segundo proyecto. GORM solo si el equipo ya lo usa o extrañas demasiado el ORM — y aún así, léete sus gotchas de rendimiento primero.
// sin @Transactional, con defer
Transacciones y NULLs
func transferir(ctx context.Context, db *sql.DB, de, a int, monto float64) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // no-op si ya hubo Commit: es seguro llamarlo siempre
if _, err := tx.ExecContext(ctx,
`UPDATE cuentas SET saldo = saldo - $1 WHERE id = $2`, monto, de); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`UPDATE cuentas SET saldo = saldo + $1 WHERE id = $2`, monto, a); err != nil {
return err
}
return tx.Commit()
}vs @TransactionalSin proxies, sin "por qué no se abrió la transacción si anoté el método" (el clásico self-invocation de Spring). El alcance transaccional es visible: empieza en BeginTx, termina en Commit. Aburrido y a prueba de sustos.
Columnas NULL: dos estilos
// Estilo 1: los tipos Null de la stdlib (explícitos, algo verbosos)
type Perfil struct {
Nombre string
Bio sql.NullString // {String: "...", Valid: bool}
}
if p.Bio.Valid {
fmt.Println(p.Bio.String)
}
// Estilo 2: punteros (más natural si el struct también va a JSON)
type Perfil2 struct {
Nombre string `json:"nombre"`
Bio *string `json:"bio,omitempty"` // nil = NULL en BD y ausente en JSON
}
// Estilo 3 (Go 1.22+): sql.Null[T] genérico
var edad sql.Null[int64]Migraciones, Redis, Mongo y SQLite en dos minutos
- Migraciones:
golang-migrateogoose(tu Flyway) — archivos SQL numerados, comandoup/down, tabla de versión. sqlc los lee para conocer tu esquema. - Redis:
redis/go-redis— cliente maduro, API directa:rdb.Set(ctx, "clave", v, ttl),rdb.Get(ctx, "clave").Result(). - MongoDB: driver oficial
mongo-driver; verboso pero sólido. Los structs usan tagsbson:igual quejson:. - SQLite:
modernc.org/sqlite(sin CGO) — perfecto para CLIs, prototipos y apps pequeñas: la base de datos también viaja dentro del binario-carpeta.