// 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.

repo.go — database/sql + pgx
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

HerramientaQué esEquivalente mental
database/sqlStdlib: SQL crudo + Scan manualJDBC, pero ergonómico
sqlxStdlib + mapeo automático a structsSpring JdbcTemplate + RowMapper
sqlc ★Escribes SQL, GENERA el código Go tipadoNo existe en Java; lo más "Go" que hay
GORMORM completo: migraciones, hooks, relacionesHibernate/JPA; popular pero divide opiniones
entORM por generación de código (de Meta)JPA + Criteria API tipado

sqlx: el punto dulce para empezar

sqlx.go
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.

consultas.sql — la fuente de verdad
-- 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;
main.go — usando el código generado
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)
i

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

tx.go — el patrón defer-rollback
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

nulls.go
// 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-migrate o goose (tu Flyway) — archivos SQL numerados, comando up/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 tags bson: igual que json:.
  • 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.