Cuando interactúas con una base de datos en Go, no estás simplemente “recibiendo datos”; estás gestionando un flujo de bytes que el driver debe interpretar y copiar en la memoria de tu aplicación. El paquete database/sql abstrae la lógica de la conexión, pero el éxito de tu capa de datos depende de cómo gestiones el ciclo de vida de los cursores y la transición de la nulidad de SQL a la tipado de Go.
Cuando ejecutas db.QueryContext, el driver abre un cursor que mantiene una conexión ocupada de la pool; si no llamas a rows.Close(), agotarás las conexiones disponibles y tu aplicación se congelará bajo carga. Si usas un loop con rows.Next(), es imperativo verificar rows.Err() al finalizar, ya que una interrupción en la red durante la iteración hará que el loop termine prematuramente sin lanzar un error dentro del ciclo. Para el mapeo, Scan es el mecanismo que copia los valores del buffer del driver a tus variables; si intentas escanear un NULL de la base de datos en una variable de tipo string estándar, el runtime te devolverá un error porque un string en Go no puede ser nil. Para manejar esta opcionalidad, puedes usar tipos específicos como sql.NullString o, una forma más moderna y limpia para estructuras de dominio, utilizar punteros (*string), donde un NULL se traduce directamente en un puntero nil. Finalmente, siempre usa parámetros en tus queries ($1, ?) en lugar de concatenar strings; esto fuerza el uso de prepared statements, lo que no solo previene ataques de SQL injection, sino que permite al motor de la base de datos reutilizar el plan de ejecución.
package main
import (
"context"
"database/sql"
"fmt"
"log"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq" // Driver de ejemplo para PostgreSQL
)
// User representa nuestra entidad de dominio.
// Usamos sql.NullString para campos donde la nulidad es lógica de negocio,
// y punteros (*string) para otros donde la ausencia de valor es un dato técnico.
type User struct {
ID int `db:"id"`
Username string `db:"username"`
Email sql.NullString `db:"email"` // sql.NullString: Explícito, seguro para lógica de negocio.
Bio *string `db:"bio"` // *string: Elegante para JSON y estructuras de dominio.
CreatedAt time.Time `db:"created_at"`
}
func main() {
// En producción, usa una conexión configurada con un pool adecuado.
// sqlx es un wrapper sobre database/sql que facilita el mapeo a structs.
db, err := sqlx.Connect("postgres", "postgres://user:pass@localhost:5432/dbname?sslmode=disable")
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 1. QueryRowContext: Ideal para cuando esperas exactamente una fila.
// El método Scan aquí es directo y devuelve sql.ErrNoRows si no hay resultados.
var singleUsername string
err = db.QueryRowContext(ctx, "SELECT username FROM users WHERE id = $1", 1).Scan(&singleUsername)
if err == sql.ErrNoRows {
fmt.Println("Usuario no encontrado")
} else if err != nil {
log.Fatal(err)
}
// 2. QueryContext: Para múltiples filas.
// Usamos sqlx para simplificar el escaneo manual que requiere db.QueryContext.
// Internamente, esto evita el boilerplate de iterar con rows.Next() y llamar a rows.Scan() manualmente.
var users []User
err = db.SelectContext(ctx, &users, "SELECT id, username, email, bio, created_at FROM users WHERE created_at > $1", time.Now().AddDate(0, 0, -7))
if err != nil {
log.Fatal(err)
}
for _, u := range users {
// Manejo de sql.NullString
emailVal := "N/A"
if u.Email.Valid {
emailVal = u.Email.String
}
// Manejo de punteros (*string)
bioVal := "Sin biografía"
if u.Bio != nil {
bioVal = *u.Bio
}
fmt.Printf("ID: %d | User: %s | Email: %s | Bio: %s\n", u.ID, u.Username, emailVal, bioVal)
}
}
El desglose del flujo
En el ejemplo, db.SelectContext de sqlx realiza una operación que, si usáramos database/sql puro, nos obligaría a gestionar manualmente el ciclo de vida. Fíjate en la estructura User: la elección entre sql.NullString para Email y *string para Bio es crítica. Al usar db.SelectContext, el driver recorre el conjunto de resultados; para cada fila, mapea las columnas mediante los db tags.
Si estuviéramos usando db.QueryContext manualmente, la línea err := rows.Scan(&u.ID, ...) es donde ocurre la magia y el peligro: el driver lee los bytes del socket, identifica el tipo de dato y copia los valores en las direcciones de memoria de las variables proporcionadas. Si u.Bio es un *string, el driver detecta el NULL de SQL y simplemente asigna nil al puntero, lo cual es extremadamente eficiente y limpio para serializar a JSON posteriormente.
Es fundamental entender que db.SelectContext internamente maneja el defer rows.Close() y la verificación de rows.Err(). Si decidieras usar el método estándar db.QueryContext, el flujo sería:
1. Llamar a rows.Next() para mover el cursor a la siguiente fila.
2. Llamar a rows.Scan() para transferir datos.
3. Al salir del loop, llamar a rows.Err() para asegurar que la conexión no se haya cortado antes de terminar el set de resultados.
El error frecuente
Uno de los errores más sutiles y costosos en sistemas de alta concurrencia es la fuga de conexiones por omisión de rows.Close().
// ERROR: Fuga de conexión
func GetUsersBad(db *sql.DB) ([]string, error) {
rows, err := db.Query("SELECT name FROM users")
if err != nil {
return nil, err
}
// Olvidar el defer rows.Close() aquí hará que la conexión
// nunca vuelva al pool, agotando el pool rápidamente.
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
names = append(names, name)
}
// ERROR: No verificar el error del cursor
return names, rows.Err()
}
En el código anterior, si rows.Next() devuelve false debido a un error de red en medio de un stream de 10,000 filas, el programa asumirá que terminó con éxito si no verificas rows.Err(). Además, sin el Close(), esa conexión queda “bloqueada” indefinidamente, lo que eventualmente causará que todas las nuevas peticiones se queden esperando por una conexión disponible que nunca regresará a la pool.
N° 197