Conectar Go con PostgreSQL: queries, repositorios y context

Cómo conectar Go con PostgreSQL usando pgx y database/sql. Queries, repositorios, context, transacciones y errores habituales.

Cover for Conectar Go con PostgreSQL: queries, repositorios y context

En Spring tienes JPA, en Python SQLAlchemy. En Go, escribes SQL. Y honestamente, para la mayoría de servicios backend, eso es suficiente. No necesitas un ORM que te genere queries mágicas, ni un DSL que abstraiga la base de datos hasta hacerla irreconocible. Necesitas ejecutar queries, mapear resultados a structs y gestionar conexiones de forma eficiente. Go te da exactamente eso.

La filosofía es la misma que en el resto del lenguaje: explícito sobre implícito, simple sobre mágico. Vas a escribir más líneas que con JPA, pero vas a entender exactamente qué query se ejecuta, cuándo se abre una conexión y qué pasa cuando algo falla. Y cuando a las tres de la madrugada tu servicio devuelve errores 500 porque el pool de conexiones está agotado, vas a agradecer tener ese control.

La clave está en algo que muchos proyectos ignoran: la base de datos no debería filtrarse por toda tu aplicación. Go te permite mantener el acceso a datos simple sin convertirlo en arquitectura inflada. Ni JPA con sus 47 anotaciones, ni SQL suelto por todos los handlers. Hay un punto medio, y es bastante cómodo.

Si todavía no tienes clara la estructura general de un proyecto Go, revisa primero estructura de proyecto antes de seguir.


database/sql: la interfaz estándar de Go

Go incluye en su librería estándar el paquete database/sql. No es un driver, sino una interfaz. Define cómo interactuar con bases de datos relacionales, pero no sabe hablar con ninguna base de datos concreta. Para eso necesitas un driver que implemente esa interfaz.

El modelo es parecido a JDBC en Java, pero más ligero. database/sql te da:

  • Un pool de conexiones integrado y configurable.
  • Prepared statements.
  • Transacciones.
  • Scan de resultados a variables Go.

Lo que no te da:

  • Nada de mapeo automático a structs.
  • Nada de migraciones.
  • Nada de generación de queries.

Así se ve una conexión básica con database/sql:

package main

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

    _ "github.com/lib/pq" // driver PostgreSQL
)

func main() {
    connStr := "host=localhost port=5432 user=app password=secret dbname=mydb sslmode=disable"
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    if err := db.Ping(); err != nil {
        log.Fatal("no se puede conectar a PostgreSQL:", err)
    }

    fmt.Println("conectado")
}

Un detalle que confunde a mucha gente: sql.Open no abre ninguna conexión. Solo valida los argumentos y devuelve un *sql.DB, que es el pool. La primera conexión real se establece cuando ejecutas la primera query o llamas a Ping(). Siempre haz Ping() después de Open() para verificar que la base de datos es accesible.

El import con _ (blank import) es necesario porque el driver lib/pq se registra automáticamente en database/sql a través de su función init(). No lo usas directamente en tu código, pero tiene que estar importado para que el registro ocurra.

Dicho esto, lib/pq está en modo mantenimiento. Su propio README te dice que uses pgx. Hagamos caso.


pgx: el driver PostgreSQL que deberías usar

pgx es el driver PostgreSQL más completo y activo para Go. Tiene dos modos de uso:

  1. Como driver de database/sql: lo registras como driver y usas la interfaz estándar.
  2. Con su API nativa: saltas database/sql y usas directamente la API de pgx, que es más rica y eficiente.

Mi recomendación: usa la API nativa de pgx. La interfaz database/sql es genérica y te obliga a ciertas limitaciones (como no poder usar tipos PostgreSQL nativos directamente). La API nativa de pgx te da acceso completo a las funcionalidades de PostgreSQL sin perder simplicidad.

Instala pgx:

go get github.com/jackc/pgx/v5

Conexión básica con la API nativa:

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()

    pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatalf("no se puede crear el pool: %v", err)
    }
    defer pool.Close()

    if err := pool.Ping(ctx); err != nil {
        log.Fatalf("no se puede conectar a PostgreSQL: %v", err)
    }

    fmt.Println("conectado con pgx")
}

La DATABASE_URL sigue el formato estándar de PostgreSQL:

postgres://user:password@localhost:5432/mydb?sslmode=disable

pgx aporta varias ventajas sobre database/sql con lib/pq:

  • Rendimiento: usa el protocolo binario de PostgreSQL, no el textual. Menos parsing, menos allocaciones.
  • Tipos nativos: soporte directo para arrays, JSON, hstore, inet, UUID, y todos los tipos PostgreSQL sin conversiones manuales.
  • Batch queries: puedes enviar múltiples queries en un solo roundtrip.
  • COPY protocol: para cargas masivas de datos.
  • LISTEN/NOTIFY: soporte para las notificaciones de PostgreSQL.
  • pgxpool: un pool de conexiones más configurable que el de database/sql.

Si necesitas mantener compatibilidad con database/sql (por ejemplo, porque usas una librería que lo requiere), pgx funciona como driver registrable:

import (
    "database/sql"

    _ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
    db, err := sql.Open("pgx", "postgres://user:password@localhost:5432/mydb")
    // ...
}

Pero si empiezas un proyecto nuevo y tu base de datos es PostgreSQL, la API nativa es el camino.


Configuración del pool de conexiones

Un pool mal configurado es una de las fuentes más comunes de problemas en producción. Conexiones agotadas, timeouts sin explicación, deadlocks silenciosos. pgxpool te da control granular sobre todo esto.

config, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
    log.Fatalf("error parseando config: %v", err)
}

config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = 30 * time.Minute
config.MaxConnIdleTime = 5 * time.Minute
config.HealthCheckPeriod = 1 * time.Minute

pool, err := pgxpool.NewWithConfig(ctx, config)

Cada parámetro importa:

  • MaxConns: el número máximo de conexiones simultáneas. No lo pongas demasiado alto. PostgreSQL por defecto acepta 100 conexiones, y si tienes 4 instancias de tu servicio con MaxConns=50 cada una, ya estás superando el límite. Un buen punto de partida es (CPUs del servidor * 2) + 1 para el total de conexiones de PostgreSQL, y repartir entre instancias.
  • MinConns: conexiones que se mantienen abiertas incluso cuando están inactivas. Reduce la latencia del primer request tras un período de inactividad.
  • MaxConnLifetime: tiempo máximo que una conexión puede existir antes de ser cerrada y recreada. Útil para que los cambios de DNS se propaguen y para evitar conexiones zombi.
  • MaxConnIdleTime: tiempo máximo que una conexión puede estar inactiva antes de ser cerrada. Libera recursos cuando el tráfico baja.
  • HealthCheckPeriod: cada cuánto se verifican las conexiones inactivas. Si una conexión se ha roto (red, reinicio de PostgreSQL), el health check la detecta y la elimina del pool.

Un error clásico: no configurar MaxConnLifetime. Sin él, una conexión puede vivir indefinidamente. Si cambias la IP de tu servidor PostgreSQL (por failover, por ejemplo), las conexiones viejas siguen apuntando a la IP antigua y fallan. Con MaxConnLifetime, se reciclan automáticamente.

Para un servicio típico en producción, algo así funciona bien:

config.MaxConns = 20
config.MinConns = 3
config.MaxConnLifetime = 1 * time.Hour
config.MaxConnIdleTime = 10 * time.Minute
config.HealthCheckPeriod = 30 * time.Second

Si usas database/sql en lugar de pgxpool, la configuración equivalente es:

db.SetMaxOpenConns(20)
db.SetMaxIdleConns(3)
db.SetConnMaxLifetime(1 * time.Hour)
db.SetConnMaxIdleTime(10 * time.Minute)

Operaciones CRUD básicas con pgx

Vamos al grano. Supongamos una tabla de tareas:

CREATE TABLE tasks (
    id          BIGSERIAL PRIMARY KEY,
    title       TEXT NOT NULL,
    description TEXT,
    status      TEXT NOT NULL DEFAULT 'pending',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Y su struct en Go:

type Task struct {
    ID          int64
    Title       string
    Description *string // nullable
    Status      string
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

INSERT

func CreateTask(ctx context.Context, pool *pgxpool.Pool, title string, description *string) (int64, error) {
    var id int64
    err := pool.QueryRow(ctx,
        "INSERT INTO tasks (title, description) VALUES ($1, $2) RETURNING id",
        title, description,
    ).Scan(&id)
    if err != nil {
        return 0, fmt.Errorf("creando tarea: %w", err)
    }
    return id, nil
}

Observa: RETURNING id evita tener que hacer un SELECT después del INSERT. PostgreSQL te devuelve el valor generado directamente. Aprovéchalo siempre.

SELECT (una fila)

func GetTask(ctx context.Context, pool *pgxpool.Pool, id int64) (Task, error) {
    var t Task
    err := pool.QueryRow(ctx,
        "SELECT id, title, description, status, created_at, updated_at FROM tasks WHERE id = $1",
        id,
    ).Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.CreatedAt, &t.UpdatedAt)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return Task{}, fmt.Errorf("tarea %d no encontrada: %w", id, err)
        }
        return Task{}, fmt.Errorf("obteniendo tarea %d: %w", id, err)
    }
    return t, nil
}

pgx.ErrNoRows es el equivalente a sql.ErrNoRows. Compruébalo siempre cuando esperas exactamente una fila. Si no lo haces, un SELECT que no encuentra resultados te devuelve un error genérico poco útil.

SELECT (múltiples filas)

func ListTasks(ctx context.Context, pool *pgxpool.Pool, status string) ([]Task, error) {
    rows, err := pool.Query(ctx,
        "SELECT id, title, description, status, created_at, updated_at FROM tasks WHERE status = $1 ORDER BY created_at DESC",
        status,
    )
    if err != nil {
        return nil, fmt.Errorf("listando tareas: %w", err)
    }
    defer rows.Close()

    var tasks []Task
    for rows.Next() {
        var t Task
        if err := rows.Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil {
            return nil, fmt.Errorf("escaneando tarea: %w", err)
        }
        tasks = append(tasks, t)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("iterando tareas: %w", err)
    }
    return tasks, nil
}

Tres cosas importantes aquí:

  1. defer rows.Close(): siempre. Si no cierras los rows, la conexión no se devuelve al pool y eventualmente te quedas sin conexiones.
  2. rows.Err(): compruébalo después del loop. rows.Next() puede dejar de iterar por un error de red, no solo porque se acabaron las filas. Si no compruebas rows.Err(), pierdes ese error silenciosamente.
  3. pgx también ofrece pgx.CollectRows, una alternativa más limpia:
func ListTasks(ctx context.Context, pool *pgxpool.Pool, status string) ([]Task, error) {
    rows, err := pool.Query(ctx,
        "SELECT id, title, description, status, created_at, updated_at FROM tasks WHERE status = $1 ORDER BY created_at DESC",
        status,
    )
    if err != nil {
        return nil, fmt.Errorf("listando tareas: %w", err)
    }
    return pgx.CollectRows(rows, pgx.RowToStructByName[Task])
}

pgx.RowToStructByName mapea columnas a campos del struct por nombre. Para que funcione, los campos del struct necesitan tags db:

type Task struct {
    ID          int64     `db:"id"`
    Title       string    `db:"title"`
    Description *string   `db:"description"`
    Status      string    `db:"status"`
    CreatedAt   time.Time `db:"created_at"`
    UpdatedAt   time.Time `db:"updated_at"`
}

UPDATE

func UpdateTaskStatus(ctx context.Context, pool *pgxpool.Pool, id int64, status string) error {
    result, err := pool.Exec(ctx,
        "UPDATE tasks SET status = $1, updated_at = NOW() WHERE id = $2",
        status, id,
    )
    if err != nil {
        return fmt.Errorf("actualizando tarea %d: %w", id, err)
    }
    if result.RowsAffected() == 0 {
        return fmt.Errorf("tarea %d no encontrada", id)
    }
    return nil
}

Exec devuelve un pgconn.CommandTag del que puedes extraer RowsAffected(). Compruébalo siempre en UPDATEs y DELETEs para confirmar que la operación afectó a alguna fila.

DELETE

func DeleteTask(ctx context.Context, pool *pgxpool.Pool, id int64) error {
    result, err := pool.Exec(ctx, "DELETE FROM tasks WHERE id = $1", id)
    if err != nil {
        return fmt.Errorf("eliminando tarea %d: %w", id, err)
    }
    if result.RowsAffected() == 0 {
        return fmt.Errorf("tarea %d no encontrada", id)
    }
    return nil
}

Context: timeouts y cancelación

Cada operación de pgx recibe un context.Context como primer argumento. No es opcional, no es decorativo. Es tu mecanismo principal para controlar timeouts y cancelación.

Si vienes de otros lenguajes, quizás pienses que el timeout se configura en el pool o en el driver. En Go, el timeout viaja con la request a través del context. Esto tiene una ventaja enorme: cada operación puede tener su propio timeout, y la cancelación se propaga automáticamente.

func GetTaskWithTimeout(pool *pgxpool.Pool, id int64) (Task, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    var t Task
    err := pool.QueryRow(ctx,
        "SELECT id, title, description, status, created_at, updated_at FROM tasks WHERE id = $1",
        id,
    ).Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.CreatedAt, &t.UpdatedAt)
    return t, err
}

Si la query tarda más de 3 segundos, pgx cancela la query en PostgreSQL (envía un CancelRequest) y devuelve un error de context. No se queda bloqueado esperando indefinidamente.

En una API REST, el context normalmente viene del HTTP request:

func handleGetTask(pool *pgxpool.Pool) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // r.Context() se cancela automáticamente si el cliente cierra la conexión
        task, err := GetTask(r.Context(), pool, taskID)
        if err != nil {
            // ...
        }
        // ...
    }
}

Esto significa que si un cliente cancela su request HTTP, la query en PostgreSQL también se cancela. No desperdicias recursos ejecutando queries cuyo resultado nadie va a leer.

Para entender context en profundidad, revisa context en Go.

Un patrón que recomiendo: si tu función no recibe un context del exterior, no uses context.Background() directamente. Siempre añade un timeout razonable:

func (r *TaskRepo) Cleanup() error {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    _, err := r.pool.Exec(ctx, "DELETE FROM tasks WHERE status = 'deleted' AND updated_at < NOW() - INTERVAL '30 days'")
    return err
}

Si esa query de limpieza se atasca por un lock, en 30 segundos se cancela y tu goroutine no se queda colgada para siempre.


El patrón repositorio: acceso a datos limpio

Tener queries SQL dispersas por los handlers HTTP es el camino directo al caos. El patrón repositorio encapsula todo el acceso a datos detrás de una interfaz clara. No es “arquitectura hexagonal Enterprise edition” — es sentido común: separar qué datos necesitas de cómo los obtienes.

// repository.go
type TaskRepository interface {
    Create(ctx context.Context, title string, description *string) (int64, error)
    GetByID(ctx context.Context, id int64) (Task, error)
    List(ctx context.Context, status string) ([]Task, error)
    UpdateStatus(ctx context.Context, id int64, status string) error
    Delete(ctx context.Context, id int64) error
}

La implementación concreta usa pgxpool:

// postgres_repository.go
type PostgresTaskRepository struct {
    pool *pgxpool.Pool
}

func NewPostgresTaskRepository(pool *pgxpool.Pool) *PostgresTaskRepository {
    return &PostgresTaskRepository{pool: pool}
}

func (r *PostgresTaskRepository) Create(ctx context.Context, title string, description *string) (int64, error) {
    var id int64
    err := r.pool.QueryRow(ctx,
        "INSERT INTO tasks (title, description) VALUES ($1, $2) RETURNING id",
        title, description,
    ).Scan(&id)
    if err != nil {
        return 0, fmt.Errorf("creando tarea: %w", err)
    }
    return id, nil
}

func (r *PostgresTaskRepository) GetByID(ctx context.Context, id int64) (Task, error) {
    var t Task
    err := r.pool.QueryRow(ctx,
        "SELECT id, title, description, status, created_at, updated_at FROM tasks WHERE id = $1",
        id,
    ).Scan(&t.ID, &t.Title, &t.Description, &t.Status, &t.CreatedAt, &t.UpdatedAt)
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return Task{}, ErrTaskNotFound
        }
        return Task{}, fmt.Errorf("obteniendo tarea %d: %w", id, err)
    }
    return t, nil
}

// ... rest of methods

Los handlers reciben la interfaz, no la implementación concreta:

type TaskHandler struct {
    repo TaskRepository
}

func (h *TaskHandler) HandleGetTask(w http.ResponseWriter, r *http.Request) {
    task, err := h.repo.GetByID(r.Context(), taskID)
    if errors.Is(err, ErrTaskNotFound) {
        http.Error(w, "tarea no encontrada", http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, "error interno", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(task)
}

Ventajas concretas:

  1. Testing: puedes mockear el repositorio para tests de handlers sin necesitar PostgreSQL. Los tests unitarios van rápido.
  2. Errores de dominio: el repositorio traduce pgx.ErrNoRows a ErrTaskNotFound. El handler no sabe nada de pgx.
  3. Cambiar de driver: si un día necesitas cambiar de pgx a otra cosa (improbable, pero posible), solo cambias la implementación del repositorio.

Un error que veo a menudo: repositorios que reciben y devuelven DTOs del handler o structs de la API. No hagas eso. El repositorio trabaja con modelos de dominio. La conversión entre modelos de dominio y DTOs de API es responsabilidad del handler o de una capa intermedia.

Para más contexto sobre cómo encaja el repositorio dentro de un servicio REST completo, revisa API REST con Go.


Transacciones: begin, commit, rollback

Cuando necesitas que varias operaciones de base de datos sean atómicas, usas transacciones. pgx hace esto limpio:

func (r *PostgresTaskRepository) CreateWithSubtasks(ctx context.Context, title string, subtasks []string) (int64, error) {
    tx, err := r.pool.Begin(ctx)
    if err != nil {
        return 0, fmt.Errorf("iniciando transacción: %w", err)
    }
    defer tx.Rollback(ctx) // no-op si ya se hizo commit

    var taskID int64
    err = tx.QueryRow(ctx,
        "INSERT INTO tasks (title) VALUES ($1) RETURNING id",
        title,
    ).Scan(&taskID)
    if err != nil {
        return 0, fmt.Errorf("creando tarea: %w", err)
    }

    for _, st := range subtasks {
        _, err = tx.Exec(ctx,
            "INSERT INTO subtasks (task_id, title) VALUES ($1, $2)",
            taskID, st,
        )
        if err != nil {
            return 0, fmt.Errorf("creando subtarea: %w", err)
        }
    }

    if err = tx.Commit(ctx); err != nil {
        return 0, fmt.Errorf("commit: %w", err)
    }
    return taskID, nil
}

El defer tx.Rollback(ctx) es una red de seguridad. Si la función retorna con error antes de llegar al Commit, el rollback se ejecuta automáticamente. Si ya se hizo commit, el rollback es un no-op. Es un patrón idiomático en Go y deberías usarlo siempre.

pgx.BeginTxFunc: transacciones con callback

pgx ofrece una alternativa que reduce el boilerplate:

func (r *PostgresTaskRepository) CreateWithSubtasks(ctx context.Context, title string, subtasks []string) (int64, error) {
    var taskID int64

    err := pgx.BeginTxFunc(ctx, r.pool, pgx.TxOptions{}, func(tx pgx.Tx) error {
        err := tx.QueryRow(ctx,
            "INSERT INTO tasks (title) VALUES ($1) RETURNING id",
            title,
        ).Scan(&taskID)
        if err != nil {
            return err
        }

        for _, st := range subtasks {
            _, err = tx.Exec(ctx,
                "INSERT INTO subtasks (task_id, title) VALUES ($1, $2)",
                taskID, st,
            )
            if err != nil {
                return err
            }
        }
        return nil
    })
    if err != nil {
        return 0, fmt.Errorf("creando tarea con subtareas: %w", err)
    }
    return taskID, nil
}

BeginTxFunc hace Begin, ejecuta tu función, y hace Commit o Rollback automáticamente según si la función devuelve nil o error. Menos código, menos oportunidades de olvidarte del rollback.

Pasar transacciones entre repositorios

En casos donde necesitas coordinar operaciones entre diferentes repositorios dentro de la misma transacción, un patrón que funciona bien es aceptar una interfaz que implemente tanto pgxpool.Pool como pgx.Tx:

// DBTX es la interfaz común entre pool y transacción
type DBTX interface {
    Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
    Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
    QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}

type PostgresTaskRepository struct {
    db DBTX
}

Así el mismo repositorio funciona con una conexión del pool o dentro de una transacción:

// Uso normal
repo := NewPostgresTaskRepository(pool)

// Dentro de una transacción
tx, _ := pool.Begin(ctx)
txRepo := NewPostgresTaskRepository(tx)

Manejo de valores NULL

Los NULL de SQL son uno de los puntos donde Go te obliga a ser explícito. No puedes scanear un NULL en un string — Go te devolverá un error. Tienes dos opciones principales.

Punteros

La forma más directa: usa punteros. Un *string puede ser nil (NULL) o apuntar a un valor.

type Task struct {
    ID          int64
    Title       string
    Description *string // NULL -> nil
}

Es simple y funciona. La desventaja es que trabajar con punteros es incómodo: tienes que comprobar nil antes de acceder al valor, y serializar a JSON requiere que lo pienses.

if t.Description != nil {
    fmt.Println(*t.Description)
}

pgtype

pgx incluye el paquete pgtype con tipos que representan valores nullable de forma más explícita:

import "github.com/jackc/pgx/v5/pgtype"

type Task struct {
    ID          int64
    Title       string
    Description pgtype.Text // tiene campos Value y Valid
}
if t.Description.Valid {
    fmt.Println(t.Description.String)
}

pgtype.Text serializa correctamente a JSON (null cuando Valid es false, el string cuando es true). Y expresa la intención más claramente que un puntero.

Mi recomendación: usa punteros para modelos simples donde la semántica es obvia. Usa pgtype cuando necesites serialización JSON correcta o cuando el modelo tiene muchos campos nullable y quieres evitar la proliferación de punteros.

Hay una tercera opción que merece mención: sql.NullString, sql.NullInt64, etc., del paquete database/sql. Funcionan, pero su serialización JSON es horrible (un objeto con String y Valid como campos), así que solo úsalos si estás trabajando con database/sql y no te importa la serialización.


Migraciones: goose y golang-migrate

Escribir SQL a mano está bien. Gestionar el schema de tu base de datos manualmente no. Necesitas migraciones versionadas y reproducibles.

Las dos herramientas más usadas en Go son goose y golang-migrate.

goose

go install github.com/pressly/goose/v3/cmd/goose@latest

Crear una migración:

goose -dir migrations create add_tasks_table sql

Eso genera un archivo como 20260708120000_add_tasks_table.sql:

-- +goose Up
CREATE TABLE tasks (
    id          BIGSERIAL PRIMARY KEY,
    title       TEXT NOT NULL,
    description TEXT,
    status      TEXT NOT NULL DEFAULT 'pending',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_tasks_status ON tasks (status);

-- +goose Down
DROP TABLE IF EXISTS tasks;

Ejecutar migraciones:

goose -dir migrations postgres "postgres://user:password@localhost:5432/mydb?sslmode=disable" up

goose también permite ejecutar migraciones desde código Go, lo cual es útil para tests:

import "github.com/pressly/goose/v3"

func RunMigrations(db *sql.DB) error {
    goose.SetDialect("postgres")
    return goose.Up(db, "migrations")
}

golang-migrate

go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

golang-migrate usa dos archivos por migración (up y down):

migrate create -ext sql -dir migrations -seq add_tasks_table

Genera 000001_add_tasks_table.up.sql y 000001_add_tasks_table.down.sql.

La diferencia principal: goose usa un solo archivo con marcadores -- +goose Up y -- +goose Down. golang-migrate usa archivos separados. Ambos funcionan bien. Yo prefiero goose por simplicidad, pero es cuestión de gusto.

Lo importante no es cuál elijas, sino que elijas uno y lo uses siempre. Nada de “voy a cambiar el schema directamente en producción y luego ya creo la migración”. Ese camino termina en desastre.


Errores habituales y debugging

Después de trabajar con Go y PostgreSQL un tiempo, hay errores que se repiten. Aquí van los más comunes y cómo resolverlos.

”conn busy”

conn busy

Estás intentando usar una conexión que ya tiene una operación en curso. Causa habitual: no cerraste los rows de una query anterior antes de ejecutar otra query en la misma conexión.

// MAL: rows no se cierra antes de la siguiente query
rows, _ := pool.Query(ctx, "SELECT ...")
for rows.Next() {
    // haces otra query aquí usando pool
    pool.QueryRow(ctx, "SELECT ...") // conn busy
}

Solución: cierra los rows antes de hacer otra operación, o usa el pool (no una conexión individual) para la segunda query.

”too many connections”

FATAL: too many clients already

Tu aplicación está abriendo más conexiones de las que PostgreSQL permite. Revisa MaxConns en tu pool y asegúrate de que el total de conexiones de todas tus instancias no supera max_connections de PostgreSQL (por defecto 100).

Scan a tipo incorrecto

can't scan into dest[3]: cannot assign NULL to *string

Estás intentando scanear un valor NULL en un tipo no nullable. Usa un puntero (*string) o pgtype.Text.

Context cancelado pero la query sigue corriendo

Si cancelas un context pero la query sigue ejecutándose en PostgreSQL, puede ser que estés usando database/sql en vez de pgx nativo. database/sql no siempre envía la señal de cancelación a PostgreSQL. pgx nativo sí lo hace correctamente.

Queries lentas sin explicación

Activa el logging de queries en pgx para ver exactamente qué se ejecuta:

config, _ := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
config.ConnConfig.Tracer = &tracelog.TraceLog{
    Logger:   tracelog.LoggerFunc(myLogFunction),
    LogLevel: tracelog.LogLevelDebug,
}

O, en PostgreSQL, activa log_min_duration_statement:

ALTER SYSTEM SET log_min_duration_statement = '100ms';
SELECT pg_reload_conf();

Eso loguea cualquier query que tarde más de 100ms. Combinado con EXPLAIN ANALYZE, te da una imagen clara de dónde está el cuello de botella.

SQL injection

pgx usa prepared statements con parámetros posicionales ($1, $2…). Mientras uses esos placeholders, estás protegido contra inyección SQL. Nunca concatenes strings para construir queries:

// MAL: inyección SQL
query := "SELECT * FROM tasks WHERE status = '" + status + "'"

// BIEN: parámetros posicionales
pool.Query(ctx, "SELECT * FROM tasks WHERE status = $1", status)

Esto parece obvio, pero lo sigo viendo en código de producción. Especialmente cuando se construyen queries dinámicas con filtros opcionales. Si necesitas queries dinámicas, usa un query builder como squirrel, o construye la query concatenando fragmentos seguros y acumula los parámetros en un slice:

query := "SELECT * FROM tasks WHERE 1=1"
args := []any{}
argPos := 1

if status != "" {
    query += fmt.Sprintf(" AND status = $%d", argPos)
    args = append(args, status)
    argPos++
}
if title != "" {
    query += fmt.Sprintf(" AND title ILIKE $%d", argPos)
    args = append(args, "%"+title+"%")
    argPos++
}

rows, err := pool.Query(ctx, query, args...)

No es bonito, pero es seguro y explícito. Y para la mayoría de casos, suficiente.


Cuándo considerar un ORM (y por qué normalmente no lo hago)

La pregunta surge siempre: “Si escribo tanto SQL a mano, no debería usar un ORM?”

Go tiene varias opciones:

  • GORM: el ORM más popular. Funcional, pero con una API que esconde demasiada magia. Las queries generadas no siempre son las que esperas, y depurar problemas de rendimiento se convierte en depurar el ORM.
  • sqlc: no es un ORM. Escribes SQL, sqlc genera código Go typesafe. Es mi opción favorita cuando el SQL a mano empieza a ser tedioso.
  • sqlx: extensiones sobre database/sql que añaden scan a structs y named parameters. Mínima magia, máxima utilidad.
  • Ent: un framework de entidades con generación de código. Más opinado que GORM, mejor en consistencia.

Mi posición: para la mayoría de servicios backend, pgx con SQL directo es suficiente. Añade un repositorio, estructura las queries, y ya está. No necesitas un ORM.

Cuando considero sqlc:

  • El proyecto tiene muchas queries y el mapeo manual se vuelve tedioso.
  • Quiero type safety en compile time, no en runtime.
  • Trabajo con un equipo donde no todos están cómodos escribiendo Go de acceso a datos.

Cuando considero un ORM (raramente):

  • Prototipo rápido donde el rendimiento de queries no importa todavía.
  • CRUD puro con lógica de negocio mínima.

Cuando nunca uso un ORM:

  • Queries complejas con JOINs, CTEs, window functions.
  • Cuando el rendimiento de la base de datos es crítico.
  • Cuando necesito control sobre las queries exactas que se ejecutan.

La realidad es que en Go, escribir SQL no es tan doloroso como en Java. No necesitas mappers XML, no necesitas entity managers, no necesitas un contenedor de IoC para inyectar el SessionFactory. Tienes un pool, tienes SQL, tienes Scan. Es directo. Y si usas testing en Go correctamente, testear tus repositorios contra una instancia real de PostgreSQL (con testcontainers, por ejemplo) es trivial.


Control y claridad por encima de magia

Trabajar con PostgreSQL en Go se reduce a unas pocas decisiones que marcan la diferencia. pgx como driver nativo en lugar de database/sql, un pool bien configurado con MaxConns y MaxConnLifetime, context con timeout en cada query sin excepciones, y repositorios que encapsulan el acceso a datos. Transacciones con defer tx.Rollback(ctx) como red de seguridad, migraciones versionadas con goose o golang-migrate, y parámetros posicionales siempre. Nada de concatenar strings para construir SQL, nada de cambios manuales en producción.

Go no te da la comodidad de JPA ni la magia de ActiveRecord. Te da control, claridad y un rendimiento excelente. Para la mayoría de servicios backend, eso es exactamente lo que necesitas. Escribe SQL, entiende tus queries, y deja que la base de datos haga lo que mejor sabe hacer.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados