Connectar Go amb PostgreSQL: queries, repositoris i context

Com connectar Go amb PostgreSQL usant pgx i database/sql. Queries, repositoris, context, transaccions i errors habituals.

Cover for Connectar Go amb PostgreSQL: queries, repositoris i context

A Spring tens JPA, a Python SQLAlchemy. A Go, escrius SQL. I honestament, per a la majoria de serveis backend, això és suficient. No necessites un ORM que et generi queries màgiques, ni un DSL que abstraigui la base de dades fins fer-la irreconeixible. Necessites executar queries, mapejar resultats a structs i gestionar connexions de forma eficient. Go et dona exactament això.

La filosofia és la mateixa que a la resta del llenguatge: explícit sobre implícit, simple sobre màgic. Escriuràs més línies que amb JPA, però entendràs exactament quina query s’executa, quan s’obre una connexió i què passa quan alguna cosa falla. I quan a les tres de la matinada el teu servei retorna errors 500 perquè el pool de connexions està exhaurit, agrairàs tenir aquell control.

La clau està en alguna cosa que molts projectes ignoren: la base de dades no hauria de filtrar-se per tota la teva aplicació. Go et permet mantenir l’accés a dades simple sense convertir-lo en arquitectura inflada. Ni JPA amb les seves 47 anotacions, ni SQL dispers per tots els handlers. Hi ha un punt mitjà, i és bastant còmode.

Si encara no tens clara l’estructura general d’un projecte Go, revisa primer estructura de projecte abans de continuar.


database/sql: la interfície estàndard de Go

Go inclou a la seva llibreria estàndard el paquet database/sql. No és un driver, sinó una interfície. Defineix com interactuar amb bases de dades relacionals, però no sap parlar amb cap base de dades concreta. Per a això necessites un driver que implementi aquella interfície.

El model s’assembla a JDBC a Java, però més lleuger. database/sql et dona:

  • Un pool de connexions integrat i configurable.
  • Prepared statements.
  • Transaccions.
  • Scan de resultats a variables Go.

El que no et dona:

  • Res de mapeig automàtic a structs.
  • Res de migracions.
  • Res de generació de queries.

Així es veu una connexió bàsica amb 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 es pot connectar a PostgreSQL:\", err)
    }

    fmt.Println(\"connectat\")
}

Un detall que confon molta gent: sql.Open no obre cap connexió. Només valida els arguments i retorna un *sql.DB, que és el pool. La primera connexió real s’estableix quan executes la primera query o crides Ping(). Fes sempre Ping() després de Open() per verificar que la base de dades és accessible.

L’import amb _ (blank import) és necessari perquè el driver lib/pq es registra automàticament a database/sql a través de la seva funció init(). No l’uses directament al teu codi, però ha d’estar importat perquè el registre ocorri.

Dit això, lib/pq està en mode manteniment. El seu propi README et diu que usis pgx. Fem cas.


pgx: el driver PostgreSQL que hauries d’usar

pgx és el driver PostgreSQL més complet i actiu per a Go. Té dos modes d’ús:

  1. Com a driver de database/sql: el registres com a driver i uses la interfície estàndard.
  2. Amb la seva API nativa: saltes database/sql i uses directament l’API de pgx, que és més rica i eficient.

La meva recomanació: usa l’API nativa de pgx. La interfície database/sql és genèrica i t’obliga a certes limitacions (com no poder usar tipus PostgreSQL natius directament). L’API nativa de pgx et dona accés complet a les funcionalitats de PostgreSQL sense perdre simplicitat.

Instal·la pgx:

go get github.com/jackc/pgx/v5

Connexió bàsica amb l’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 es pot crear el pool: %v\", err)
    }
    defer pool.Close()

    if err := pool.Ping(ctx); err != nil {
        log.Fatalf(\"no es pot connectar a PostgreSQL: %v\", err)
    }

    fmt.Println(\"connectat amb pgx\")
}

La DATABASE_URL segueix el format estàndard de PostgreSQL:

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

pgx aporta diversos avantatges sobre database/sql amb lib/pq:

  • Rendiment: usa el protocol binari de PostgreSQL, no el textual. Menys parsing, menys allocacions.
  • Tipus natius: suport directe per a arrays, JSON, hstore, inet, UUID, i tots els tipus PostgreSQL sense conversions manuals.
  • Batch queries: pots enviar múltiples queries en un sol roundtrip.
  • Protocol COPY: per a càrregues massives de dades.
  • LISTEN/NOTIFY: suport per a les notificacions de PostgreSQL.
  • pgxpool: un pool de connexions més configurable que el de database/sql.

Si necessites mantenir compatibilitat amb database/sql (per exemple, perquè uses una llibreria que ho requereix), pgx funciona com a driver registrable:

import (
    \"database/sql\"

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

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

Però si comences un projecte nou i la teva base de dades és PostgreSQL, l’API nativa és el camí.


Configuració del pool de connexions

Un pool mal configurat és una de les fonts més comunes de problemes en producció. Connexions esgotades, timeouts sense explicació, deadlocks silenciosos. pgxpool et dona control granular sobre tot això.

config, err := pgxpool.ParseConfig(os.Getenv(\"DATABASE_URL\"))
if err != nil {
    log.Fatalf(\"error parsejant 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àmetre importa:

  • MaxConns: el nombre màxim de connexions simultànies. No el posis massa alt. PostgreSQL per defecte accepta 100 connexions, i si tens 4 instàncies del teu servei amb MaxConns=50 cadascuna, ja estàs superant el límit. Un bon punt de partida és (CPUs del servidor * 2) + 1 per al total de connexions de PostgreSQL, i repartir entre instàncies.
  • MinConns: connexions que es mantenen obertes fins i tot quan estan inactives. Redueix la latència de la primera petició després d’un període d’inactivitat.
  • MaxConnLifetime: temps màxim que una connexió pot existir abans de ser tancada i recreada. Útil perquè els canvis de DNS es propaguin i per evitar connexions zombi.
  • MaxConnIdleTime: temps màxim que una connexió pot estar inactiva abans de ser tancada. Allibera recursos quan el tràfic baixa.
  • HealthCheckPeriod: cada quant es verifiquen les connexions inactives. Si una connexió s’ha trencat (xarxa, reinici de PostgreSQL), el health check la detecta i l’elimina del pool.

Un error clàssic: no configurar MaxConnLifetime. Sense ell, una connexió pot viure indefinidament. Si canvies la IP del teu servidor PostgreSQL (per failover, per exemple), les connexions velles segueixen apuntant a la IP antiga i fallen. Amb MaxConnLifetime, es reciclen automàticament.

Per a un servei típic en producció, alguna cosa així funciona bé:

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

Si uses database/sql en lloc de pgxpool, la configuració equivalent és:

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

Operacions CRUD bàsiques amb pgx

Anem al gra. Suposem una taula de tasques:

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()
);

I el seu 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(\"creant tasca: %w\", err)
    }
    return id, nil
}

Observa: RETURNING id evita haver de fer un SELECT després de l’INSERT. PostgreSQL et retorna el valor generat directament. Aprofita-ho sempre.

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(\"tasca %d no trobada: %w\", id, err)
        }
        return Task{}, fmt.Errorf(\"obtenint tasca %d: %w\", id, err)
    }
    return t, nil
}

pgx.ErrNoRows és l’equivalent a sql.ErrNoRows. Comprova-ho sempre quan esperes exactament una fila. Si no ho fas, un SELECT que no troba resultats et retorna un error genèric poc útil.

SELECT (múltiples files)

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(\"llistant tasques: %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(\"escanejant tasca: %w\", err)
        }
        tasks = append(tasks, t)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf(\"iterant tasques: %w\", err)
    }
    return tasks, nil
}

Tres coses importants aquí:

  1. defer rows.Close(): sempre. Si no tanques els rows, la connexió no es retorna al pool i eventualment et quedas sense connexions.
  2. rows.Err(): comprova-ho després del loop. rows.Next() pot deixar d’iterar per un error de xarxa, no només perquè s’han acabat les files. Si no comproves rows.Err(), perds aquell error silenciosament.
  3. pgx també ofereix pgx.CollectRows, una alternativa més neta:
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(\"llistant tasques: %w\", err)
    }
    return pgx.CollectRows(rows, pgx.RowToStructByName[Task])
}

pgx.RowToStructByName mapeja columnes a camps de l’struct per nom. Perquè funcioni, els camps de l’struct necessiten 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(\"actualitzant tasca %d: %w\", id, err)
    }
    if result.RowsAffected() == 0 {
        return fmt.Errorf(\"tasca %d no trobada\", id)
    }
    return nil
}

Exec retorna un pgconn.CommandTag del qual pots extreure RowsAffected(). Comprova-ho sempre en UPDATEs i DELETEs per confirmar que l’operació ha afectat 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(\"eliminant tasca %d: %w\", id, err)
    }
    if result.RowsAffected() == 0 {
        return fmt.Errorf(\"tasca %d no trobada\", id)
    }
    return nil
}

Context: timeouts i cancel·lació

Cada operació de pgx rep un context.Context com a primer argument. No és opcional, no és decoratiu. És el teu mecanisme principal per controlar timeouts i cancel·lació.

Si véns d’altres llenguatges, potser pensis que el timeout es configura al pool o al driver. A Go, el timeout viatja amb la petició a través del context. Això té un avantatge enorme: cada operació pot tenir el seu propi timeout, i la cancel·lació es propaga automàticament.

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 segons, pgx cancel·la la query a PostgreSQL (envia un CancelRequest) i retorna un error de context. No es queda bloquejat esperant indefinidament.

En una API REST, el context normalment ve de la petició HTTP:

func handleGetTask(pool *pgxpool.Pool) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // r.Context() es cancel·la automàticament si el client tanca la connexió
        task, err := GetTask(r.Context(), pool, taskID)
        if err != nil {
            // ...
        }
        // ...
    }
}

Això significa que si un client cancel·la la seva petició HTTP, la query a PostgreSQL també es cancel·la. No malbarates recursos executant queries el resultat de les quals ningú llegirà.

Per entendre el context en profunditat, revisa context en Go.

Un patró que recomano: si la teva funció no rep un context de l’exterior, no usis context.Background() directament. Afegeix sempre un timeout raonable:

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 aquella query de neteja s’encalla per un lock, en 30 segons es cancel·la i la teva goroutine no es queda penjada per sempre.


El patró repositori: accés a dades net

Tenir queries SQL disperses pels handlers HTTP és el camí directe al caos. El patró repositori encapsula tot l’accés a dades darrere d’una interfície clara. No és “arquitectura hexagonal Enterprise edition” — és sentit comú: separar quines dades necessites de com les obtens.

// 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ó 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(\"creant tasca: %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(\"obtenint tasca %d: %w\", id, err)
    }
    return t, nil
}

// ... resta de mètodes

Els handlers reben la interfície, no la implementació 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, \"tasca no trobada\", http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, \"error intern\", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(task)
}

Avantatges concrets:

  1. Testing: pots mockejar el repositori per a tests de handlers sense necessitar PostgreSQL. Els tests unitaris van ràpid.
  2. Errors de domini: el repositori tradueix pgx.ErrNoRows a ErrTaskNotFound. El handler no sap res de pgx.
  3. Canviar de driver: si un dia necessites canviar de pgx a una altra cosa (improbable, però possible), només canvies la implementació del repositori.

Un error que veig sovint: repositoris que reben i retornen DTOs del handler o structs de l’API. No facis això. El repositori treballa amb models de domini. La conversió entre models de domini i DTOs d’API és responsabilitat del handler o d’una capa intermèdia.

Per a més context sobre com encaixa el repositori dins d’un servei REST complet, revisa API REST amb Go.


Transaccions: begin, commit, rollback

Quan necessites que diverses operacions de base de dades siguin atòmiques, uses transaccions. pgx fa això net:

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(\"iniciant transacció: %w\", err)
    }
    defer tx.Rollback(ctx) // no-op si ja s'ha fet 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(\"creant tasca: %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(\"creant subtasca: %w\", err)
        }
    }

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

El defer tx.Rollback(ctx) és una xarxa de seguretat. Si la funció retorna amb error abans d’arribar al Commit, el rollback s’executa automàticament. Si ja s’ha fet commit, el rollback és un no-op. És un patró idiomàtic en Go i hauríes d’usar-lo sempre.

pgx.BeginTxFunc: transaccions amb callback

pgx ofereix una alternativa que redueix 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(\"creant tasca amb subtasques: %w\", err)
    }
    return taskID, nil
}

BeginTxFunc fa Begin, executa la teva funció, i fa Commit o Rollback automàticament segons si la funció retorna nil o error. Menys codi, menys oportunitats d’oblidar-se del rollback.

Passar transaccions entre repositoris

En casos on necessites coordinar operacions entre diferents repositoris dins de la mateixa transacció, un patró que funciona bé és acceptar una interfície que implementi tant pgxpool.Pool com pgx.Tx:

// DBTX és la interfície comuna entre pool i transacció
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
}

Així el mateix repositori funciona amb una connexió del pool o dins d’una transacció:

// Ús normal
repo := NewPostgresTaskRepository(pool)

// Dins d'una transacció
tx, _ := pool.Begin(ctx)
txRepo := NewPostgresTaskRepository(tx)

Gestió de valors NULL

Els NULL de SQL són un dels punts on Go t’obliga a ser explícit. No pots escanejar un NULL en un string — Go et retornarà un error. Tens dues opcions principals.

Punters

La forma més directa: usa punters. Un *string pot ser nil (NULL) o apuntar a un valor.

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

És simple i funciona. El desavantatge és que treballar amb punters és incòmode: has de comprovar nil abans d’accedir al valor, i serialitzar a JSON requereix que ho pensis.

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

pgtype

pgx inclou el paquet pgtype amb tipus que representen valors nullable de forma més explícita:

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

type Task struct {
    ID          int64
    Title       string
    Description pgtype.Text // té camps Value i Valid
}
if t.Description.Valid {
    fmt.Println(t.Description.String)
}

pgtype.Text serialitza correctament a JSON (null quan Valid és false, el string quan és true). I expressa la intenció més clarament que un punter.

La meva recomanació: usa punters per a models simples on la semàntica és òbvia. Usa pgtype quan necessitis serialització JSON correcta o quan el model té molts camps nullable i vols evitar la proliferació de punters.

Hi ha una tercera opció que mereix menció: sql.NullString, sql.NullInt64, etc., del paquet database/sql. Funcionen, però la seva serialització JSON és horrible (un objecte amb String i Valid com a camps), així que només usa’ls si estàs treballant amb database/sql i no t’importa la serialització.


Migracions: goose i golang-migrate

Escriure SQL a mà està bé. Gestionar l’schema de la teva base de dades manualment no. Necessites migracions versionades i reproduïbles.

Les dues eines més usades en Go són goose i golang-migrate.

goose

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

Crear una migració:

goose -dir migrations create add_tasks_table sql

Això genera un fitxer com 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;

Executar migracions:

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

goose també permet executar migracions des de codi Go, cosa que és útil per a 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 fitxers per migració (up i down):

migrate create -ext sql -dir migrations -seq add_tasks_table

Genera 000001_add_tasks_table.up.sql i 000001_add_tasks_table.down.sql.

La diferència principal: goose usa un sol fitxer amb marcadors -- +goose Up i -- +goose Down. golang-migrate usa fitxers separats. Ambdós funcionen bé. Jo prefereixo goose per simplicitat, però és qüestió de gust.

El que importa no és quin tries, sinó que en tries un i l’usis sempre. Res de “canviaré l’schema directament en producció i ja crearé la migració”. Aquell camí acaba en desastre.


Errors habituals i debugging

Després de treballar amb Go i PostgreSQL un temps, hi ha errors que es repeteixen. Aquí van els més comuns i com resoldre’ls.

”conn busy”

conn busy

Estàs intentant usar una connexió que ja té una operació en curs. Causa habitual: no has tancat els rows d’una query anterior abans d’executar una altra query a la mateixa connexió.

// MAL: els rows no es tanquen abans de la query següent
rows, _ := pool.Query(ctx, \"SELECT ...\")
for rows.Next() {
    // fas una altra query aquí usant pool
    pool.QueryRow(ctx, \"SELECT ...\") // conn busy
}

Solució: tanca els rows abans de fer una altra operació, o usa el pool (no una connexió individual) per a la segona query.

”too many connections”

FATAL: too many clients already

La teva aplicació està obrint més connexions de les que PostgreSQL permet. Revisa MaxConns al teu pool i assegura’t que el total de connexions de totes les teves instàncies no supera max_connections de PostgreSQL (per defecte 100).

Scan a tipus incorrecte

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

Estàs intentant escanejar un valor NULL en un tipus no nullable. Usa un punter (*string) o pgtype.Text.

Context cancel·lat però la query continua executant-se

Si cancel·les un context però la query continua executant-se a PostgreSQL, pot ser que estiguis usant database/sql en lloc de pgx natiu. database/sql no sempre envia el senyal de cancel·lació a PostgreSQL. pgx natiu sí ho fa correctament.

Queries lentes sense explicació

Activa el logging de queries en pgx per veure exactament què s’executa:

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

O, a PostgreSQL, activa log_min_duration_statement:

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

Això registra qualsevol query que tardi més de 100ms. Combinat amb EXPLAIN ANALYZE, et dona una imatge clara d’on és el coll d’ampolla.

SQL injection

pgx usa prepared statements amb paràmetres posicionals ($1, $2…). Mentre usis aquells placeholders, estàs protegit contra injecció SQL. Mai concatenis strings per construir queries:

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

// BÉ: paràmetres posicionals
pool.Query(ctx, \"SELECT * FROM tasks WHERE status = $1\", status)

Això sembla obvi, però ho segueixo veient en codi de producció. Especialment quan es construeixen queries dinàmiques amb filtres opcionals. Si necessites queries dinàmiques, usa un query builder com squirrel, o construeix la query concatenant fragments segurs i acumula els paràmetres 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 és bonic, però és segur i explícit. I per a la majoria de casos, suficient.


Quan considerar un ORM (i per què normalment no ho faig)

La pregunta sorgeix sempre: “Si escric tant SQL a mà, no hauria d’usar un ORM?”

Go té diverses opcions:

  • GORM: l’ORM més popular. Funcional, però amb una API que amaga massa màgia. Les queries generades no sempre són les que esperes, i depurar problemes de rendiment es converteix en depurar l’ORM.
  • sqlc: no és un ORM. Escrius SQL, sqlc genera codi Go typesafe. És la meva opció favorita quan el SQL a mà comença a ser tediós.
  • sqlx: extensions sobre database/sql que afegeixen scan a structs i named parameters. Mínima màgia, màxima utilitat.
  • Ent: un framework d’entitats amb generació de codi. Més opinionat que GORM, millor en consistència.

La meva posició: per a la majoria de serveis backend, pgx amb SQL directe és suficient. Afegeix un repositori, estructura les queries, i ja està. No necessites un ORM.

Quan considero sqlc:

  • El projecte té moltes queries i el mapeig manual es torna tediós.
  • Vull type safety en compile time, no en runtime.
  • Treballo amb un equip on no tothom se sent còmode escrivint Go d’accés a dades.

Quan considero un ORM (rarament):

  • Prototip ràpid on el rendiment de queries no importa encara.
  • CRUD pur amb lògica de negoci mínima.

Quan mai uso un ORM:

  • Queries complexes amb JOINs, CTEs, window functions.
  • Quan el rendiment de la base de dades és crític.
  • Quan necessito control sobre les queries exactes que s’executen.

La realitat és que en Go, escriure SQL no és tan dolorós com a Java. No necessites mappers XML, no necessites entity managers, no necessites un contenidor d’IoC per injectar el SessionFactory. Tens un pool, tens SQL, tens Scan. És directe. I si uses testing en Go correctament, testejar els teus repositoris contra una instància real de PostgreSQL (amb testcontainers, per exemple) és trivial.


Control i claredat per sobre de la màgia

Treballar amb PostgreSQL en Go es redueix a unes poques decisions que marquen la diferència. pgx com a driver natiu en lloc de database/sql, un pool ben configurat amb MaxConns i MaxConnLifetime, context amb timeout en cada query sense excepcions, i repositoris que encapsulen l’accés a dades. Transaccions amb defer tx.Rollback(ctx) com a xarxa de seguretat, migracions versionades amb goose o golang-migrate, i paràmetres posicionals sempre. Res de concatenar strings per construir SQL, res de canvis manuals en producció.

Go no et dona la comoditat de JPA ni la màgia d’ActiveRecord. Et dona control, claredat i un rendiment excel·lent. Per a la majoria de serveis backend, és exactament el que necessites. Escriu SQL, entén les teves queries, i deixa que la base de dades faci el que millor sap fer.

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats