Go com PostgreSQL: recursos avançados e o driver pgx

Go com PostgreSQL: recursos avançados e o driver pgx

Go com PostgreSQL pelo pgx: pgxpool, parâmetros nomeados, JSONB e a junção rasa do operador ||, RowToStructByName, busca textual com websearch_to_tsquery, LISTEN/NOTIFY, COPY, códigos de erro do servidor e o golang-migrate, que com o driver pgx/v5 exige o esquema pgx5://.
Golang

• • 17 min de leitura

PostgreSQL é frequentemente descrito como o banco de dados relacional mais completo disponível. Suporte nativo a JSON/JSONB, arrays, tipos geométricos, full-text search, extensões como PostGIS e pgvector, transações ACID robustas, replicação lógica e um sistema de tipos extensível fazem dele a escolha preferida para aplicações que precisam de mais do que tabelas e índices simples.

Em Go, o driver de referência é o pgx — escrito especificamente para PostgreSQL, sem tentar ser genérico. Ele expõe recursos do PostgreSQL que o database/sql padrão não alcança, como tipos nativos, COPY protocol, listen/notify e prepared statements com cache automático.

Dois modos de uso do pgx

O pgx pode ser usado de duas formas:

Modo database/sql — compatível com a interface padrão, mas perde alguns recursos avançados:

go get github.com/jackc/pgx/v5
go get github.com/jackc/pgx/v5/stdlib

Modo nativo — acesso completo a todos os recursos do PostgreSQL:

go get github.com/jackc/pgx/v5

Este artigo usa principalmente o modo nativo, que é o recomendado para novos projetos.

Configuração do ambiente

# PostgreSQL com Docker
docker run --name postgres-dev \
    -e POSTGRES_USER=usuario \
    -e POSTGRES_PASSWORD=senha \
    -e POSTGRES_DB=app \
    -p 5432:5432 \
    -d postgres:18

Conexão com pgxpool

O pgx oferece pgxpool — um pool de conexões nativo otimizado para PostgreSQL:

package main

import (
    "context"
    "fmt"
    "log"
    "time"

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

func conectarPostgres(ctx context.Context) (*pgxpool.Pool, error) {
    // DSN do PostgreSQL
    dsn := "postgres://usuario:senha@localhost:5432/app?sslmode=disable"

    config, err := pgxpool.ParseConfig(dsn)
    if err != nil {
        return nil, fmt.Errorf("parsear config: %w", err)
    }

    // Configurações do pool
    config.MaxConns = 25
    config.MinConns = 5
    config.MaxConnLifetime = 5 * time.Minute
    config.MaxConnIdleTime = 2 * time.Minute
    config.HealthCheckPeriod = 1 * time.Minute

    // Hook de inicialização de cada conexão
    config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
        // Configura o timezone da conexão
        _, err := conn.Exec(ctx, "SET timezone = 'America/Sao_Paulo'")
        return err
    }

    pool, err := pgxpool.NewWithConfig(ctx, config)
    if err != nil {
        return nil, fmt.Errorf("criar pool: %w", err)
    }

    // Verifica conectividade
    if err := pool.Ping(ctx); err != nil {
        pool.Close()
        return nil, fmt.Errorf("verificar conexão: %w", err)
    }

    log.Println("PostgreSQL conectado")
    return pool, nil
}

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

    pool, err := conectarPostgres(ctx)
    if err != nil {
        log.Fatal(err)
    }
    defer pool.Close()

    var versao string
    err = pool.QueryRow(ctx, "SELECT version()").Scan(&versao)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(versao)
}

Schema PostgreSQL com tipos avançados

-- Desde o PostgreSQL 13, gen_random_uuid() é nativo; o 18 trouxe também uuidv7(),
-- ordenado pelo tempo e melhor para índices. A extensão uuid-ossp não é mais necessária.

CREATE TABLE usuarios (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    nome       TEXT NOT NULL,
    email      TEXT NOT NULL UNIQUE,
    metadata   JSONB,
    tags       TEXT[],
    criado_em  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ativo      BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE TABLE posts (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    autor_id    UUID NOT NULL REFERENCES usuarios(id),
    titulo      TEXT NOT NULL,
    conteudo    TEXT NOT NULL,
    status      TEXT NOT NULL DEFAULT 'rascunho'
                CHECK(status IN ('rascunho', 'publicado', 'arquivado')),
    publicado_em TIMESTAMPTZ,
    criado_em   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    tsv         TSVECTOR GENERATED ALWAYS AS (
                    to_tsvector('portuguese', titulo || ' ' || conteudo)
                ) STORED
);

CREATE INDEX idx_posts_tsv ON posts USING GIN(tsv);
CREATE INDEX idx_usuarios_tags ON usuarios USING GIN(tags);
CREATE INDEX idx_usuarios_metadata ON usuarios USING GIN(metadata);

Tipos nativos do pgx

O pgx mapeia tipos PostgreSQL para tipos Go nativos sem conversão manual:

package main

import (
    "context"
    "fmt"
    "time"

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

type Usuario struct {
    ID        pgtype.UUID
    Nome      string
    Email     string
    Metadata  map[string]any
    Tags      []string
    CriadoEm time.Time
    Ativo     bool
}

func criarUsuario(ctx context.Context, pool *pgxpool.Pool, nome, email string, tags []string) (*Usuario, error) {
    query := `
        INSERT INTO usuarios (nome, email, tags)
        VALUES (@nome, @email, @tags)
        RETURNING id, nome, email, metadata, tags, criado_em, ativo
    `

    // pgx suporta parâmetros nomeados com @nome
    args := pgx.NamedArgs{
        "nome":  nome,
        "email": email,
        "tags":  tags,
    }

    var u Usuario
    err := pool.QueryRow(ctx, query, args).Scan(
        &u.ID,
        &u.Nome,
        &u.Email,
        &u.Metadata,
        &u.Tags,
        &u.CriadoEm,
        &u.Ativo,
    )
    if err != nil {
        return nil, fmt.Errorf("criar usuário: %w", err)
    }

    return &u, nil
}

JSONB: documentos dentro do relacional

type ConfiguracaoUsuario struct {
    Tema        string            `json:"tema"`
    Idioma      string            `json:"idioma"`
    Notificacoes map[string]bool  `json:"notificacoes"`
}

func atualizarMetadata(ctx context.Context, pool *pgxpool.Pool,
    userID pgtype.UUID, config ConfiguracaoUsuario) error {

    query := `
        UPDATE usuarios
        SET metadata = COALESCE(metadata, '{}'::jsonb) || @config::jsonb  -- NULL || x daria NULL
        WHERE id = @id
    `

    dados, err := json.Marshal(config)
    if err != nil {
        return err
    }

    _, err = pool.Exec(ctx, query, pgx.NamedArgs{
        "config": string(dados),
        "id":     userID,
    })
    return err
}

func buscarPorMetadata(ctx context.Context, pool *pgxpool.Pool,
    tema string) ([]Usuario, error) {

    // Consulta JSONB com operador @>
    query := `
        SELECT id, nome, email, metadata, tags, criado_em, ativo  -- todos os campos de Usuario
        FROM usuarios
        WHERE metadata @> @filtro::jsonb
        AND ativo = true
    `

    // json.Marshal escapa o valor; montar o JSON com Sprintf permitiria injetar chaves
    f, err := json.Marshal(map[string]string{"tema": tema})
    if err != nil {
        return nil, err
    }
    filtro := string(f)

    rows, err := pool.Query(ctx, query, pgx.NamedArgs{"filtro": filtro})
    if err != nil {
        return nil, fmt.Errorf("buscar por metadata: %w", err)
    }
    defer rows.Close()

    return pgx.CollectRows(rows, pgx.RowToStructByName[Usuario])
}

pgx.CollectRows: scan automático em structs

O pgx oferece utilitários para mapear rows diretamente em structs sem scan manual:

type Post struct {
    ID          pgtype.UUID `db:"id"`
    AutorID     pgtype.UUID `db:"autor_id"`
    Titulo      string      `db:"titulo"`
    Conteudo    string      `db:"conteudo"`
    Status      string      `db:"status"`
    PublicadoEm pgtype.Timestamptz `db:"publicado_em"`
    CriadoEm   time.Time   `db:"criado_em"`
}

func listarPosts(ctx context.Context, pool *pgxpool.Pool, status string, limite int) ([]Post, error) {
    query := `
        SELECT id, autor_id, titulo, conteudo, status, publicado_em, criado_em
        FROM posts
        WHERE status = @status
        ORDER BY criado_em DESC
        LIMIT @limite
    `

    rows, err := pool.Query(ctx, query, pgx.NamedArgs{
        "status": status,
        "limite": limite,
    })
    if err != nil {
        return nil, fmt.Errorf("listar posts: %w", err)
    }
    defer rows.Close()

    // CollectRows elimina o loop manual de rows.Next() + Scan()
    posts, err := pgx.CollectRows(rows, pgx.RowToStructByName[Post])
    if err != nil {
        return nil, fmt.Errorf("coletar posts: %w", err)
    }

    return posts, nil
}

Full-text search nativo

type ResultadoBusca struct {
    ID        pgtype.UUID
    Titulo    string
    Relevancia float64
    Trecho    string
}

func buscarPosts(ctx context.Context, pool *pgxpool.Pool, termo string) ([]ResultadoBusca, error) {
    query := `
        SELECT
            id,
            titulo,
            ts_rank(tsv, query) AS relevancia,
            ts_headline('portuguese', conteudo, query,
                'MaxWords=50, MinWords=20, StartSel=<b>, StopSel=</b>'
            ) AS trecho
        FROM posts, websearch_to_tsquery('portuguese', @termo) query
        WHERE tsv @@ query
        AND status = 'publicado'
        ORDER BY relevancia DESC
        LIMIT 10
    `

    rows, err := pool.Query(ctx, query, pgx.NamedArgs{"termo": termo})
    if err != nil {
        return nil, fmt.Errorf("buscar posts: %w", err)
    }
    defer rows.Close()

    var resultados []ResultadoBusca
    for rows.Next() {
        var r ResultadoBusca
        if err := rows.Scan(&r.ID, &r.Titulo, &r.Relevancia, &r.Trecho); err != nil {
            return nil, err
        }
        resultados = append(resultados, r)
    }

    return resultados, rows.Err()
}

Transações com pgx

func publicarPost(ctx context.Context, pool *pgxpool.Pool, postID pgtype.UUID) error {
    tx, err := pool.Begin(ctx)
    if err != nil {
        return fmt.Errorf("iniciar transação: %w", err)
    }
    defer tx.Rollback(ctx) // No-op se já commitado

    // Verifica se o post existe e está como rascunho
    var status string
    err = tx.QueryRow(ctx,
        "SELECT status FROM posts WHERE id = $1 FOR UPDATE",
        postID,
    ).Scan(&status)

    if errors.Is(err, pgx.ErrNoRows) {
        return fmt.Errorf("post não encontrado")
    }
    if err != nil {
        return fmt.Errorf("verificar post: %w", err)
    }
    if status != "rascunho" {
        return fmt.Errorf("post não está em rascunho: %s", status)
    }

    // Publica o post
    _, err = tx.Exec(ctx, `
        UPDATE posts
        SET status = 'publicado', publicado_em = NOW()
        WHERE id = $1
    `, postID)
    if err != nil {
        return fmt.Errorf("publicar post: %w", err)
    }

    // Registra evento de auditoria
    _, err = tx.Exec(ctx, `
        INSERT INTO auditoria (entidade, entidade_id, acao, realizado_em)
        VALUES ('post', $1, 'publicacao', NOW())
    `, postID)
    if err != nil {
        return fmt.Errorf("registrar auditoria: %w", err)
    }

    return tx.Commit(ctx)
}

Listen/Notify: eventos em tempo real

PostgreSQL suporta um mecanismo de pub/sub nativo através de LISTEN e NOTIFY. O pgx expõe isso de forma nativa:

package main

import (
    "context"
    "fmt"
    "log"

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

func escutarEventos(ctx context.Context, connStr string) error {
    // Listen/Notify requer uma conexão dedicada — não use o pool
    conn, err := pgx.Connect(ctx, connStr)
    if err != nil {
        return fmt.Errorf("conectar: %w", err)
    }
    defer conn.Close(ctx)

    // Registra para receber notificações do canal "novos_pedidos"
    _, err = conn.Exec(ctx, "LISTEN novos_pedidos")
    if err != nil {
        return fmt.Errorf("registrar listener: %w", err)
    }

    log.Println("Aguardando eventos de novos pedidos...")

    for {
        // WaitForNotification bloqueia até receber uma notificação
        notificacao, err := conn.WaitForNotification(ctx)
        if err != nil {
            if ctx.Err() != nil {
                return nil // contexto cancelado — encerramento normal
            }
            return fmt.Errorf("aguardar notificação: %w", err)
        }

        fmt.Printf("Canal: %s | PID: %d | Payload: %s\n",
            notificacao.Channel,
            notificacao.PID,
            notificacao.Payload,
        )
    }
}

// No lado do emissor — pode ser chamado de qualquer conexão
func notificarNovoPedido(ctx context.Context, pool *pgxpool.Pool, pedidoID string) error {
    _, err := pool.Exec(ctx,
        "SELECT pg_notify('novos_pedidos', $1)", pedidoID)
    return err
}

COPY: inserção em massa de alta performance

Para inserir grandes volumes de dados, o protocolo COPY é muito mais rápido que INSERTs individuais: num teste local com 20 mil linhas, levou 0,17 s, contra 4,1 s dos INSERTs um a um. A diferença cresce com a latência da rede, porque cada INSERT avulso é uma ida e volta ao servidor, enquanto o COPY manda tudo num fluxo só. O pool já oferece o CopyFrom, sem que seja preciso adquirir uma conexão à mão:

func inserirUsuariosEmMassa(ctx context.Context, pool *pgxpool.Pool, usuarios []Usuario) error {
    // CopyFrom usa o protocolo COPY do PostgreSQL
    linhas := make([][]any, len(usuarios))
    for i, u := range usuarios {
        linhas[i] = []any{u.Nome, u.Email, u.Tags}
    }

    copiados, err := pool.CopyFrom(
        ctx,
        pgx.Identifier{"usuarios"},
        []string{"nome", "email", "tags"},
        pgx.CopyFromRows(linhas),
    )
    if err != nil {
        return fmt.Errorf("copy from: %w", err)
    }

    fmt.Printf("%d usuários inseridos\n", copiados)
    return nil
}

Tratando erros específicos do PostgreSQL

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

func tratarErroPG(err error) error {
    var pgErr *pgconn.PgError
    if errors.As(err, &pgErr) {
        switch pgErr.Code {
        case "23505": // unique_violation
            return fmt.Errorf("registro duplicado: %s", pgErr.ConstraintName)
        case "23503": // foreign_key_violation
            return fmt.Errorf("referência inválida: %s", pgErr.ConstraintName)
        case "23502": // not_null_violation
            return fmt.Errorf("campo obrigatório ausente: %s", pgErr.ColumnName)
        case "42P01": // undefined_table
            // TableName vem vazio neste erro; o nome da tabela está na mensagem
            return fmt.Errorf("tabela inexistente: %s", pgErr.Message)
        default:
            return fmt.Errorf("erro PostgreSQL %s: %s", pgErr.Code, pgErr.Message)
        }
    }
    return err
}

func criarUsuarioComTratamento(ctx context.Context, pool *pgxpool.Pool,
    nome, email string) (*Usuario, error) {

    u, err := criarUsuario(ctx, pool, nome, email, nil)
    if err != nil {
        return nil, tratarErroPG(err)
    }
    return u, nil
}

Migrações com golang-migrate

Para projetos PostgreSQL em produção, golang-migrate é a ferramenta mais usada:

go get github.com/golang-migrate/migrate/v4
go get github.com/golang-migrate/migrate/v4/database/pgx/v5
go get github.com/golang-migrate/migrate/v4/source/file
migrations/
├── 000001_criar_usuarios.up.sql
├── 000001_criar_usuarios.down.sql
├── 000002_criar_posts.up.sql
└── 000002_criar_posts.down.sql
import (
    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/pgx/v5"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

// O driver database/pgx/v5 registra o esquema pgx5://, e não postgres://:
// dsn = "pgx5://usuario:senha@localhost:5432/app?sslmode=disable".
// Com postgres://, o New falha com unknown driver postgres (forgotten import?).
func executarMigracoes(dsn string) error {
    m, err := migrate.New("file://migrations", dsn)
    if err != nil {
        return fmt.Errorf("criar migrator: %w", err)
    }
    defer m.Close()

    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return fmt.Errorf("executar migrações: %w", err)
    }

    versao, _, _ := m.Version()
    log.Printf("Banco na versão %d", versao)
    return nil
}

O pgx dá acesso ao que o PostgreSQL tem de melhor — JSONB, arrays, busca textual, LISTEN/NOTIFY, COPY, erros com código — sem passar pelo mínimo comum do database/sql. Os cuidados que mais aparecem vêm justamente desses recursos: NULL que engole a junção de JSONB, to_tsquery que não aceita texto livre, JSON montado à mão que abre espaço para injeção, RowToStructByName que exige cada campo da struct. Testar contra um PostgreSQL de verdade — um contêiner ou um cluster local — pega esses casos antes da produção.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um serviço passa a rodar as migrações na subida com o golang-migrate, importando github.com/golang-migrate/migrate/v4/database/pgx/v5 e reaproveitando o mesmo DSN do pool, postgres://usuario:senha@localhost:5432/app. O programa compila, mas morre na hora de criar o migrator. Qual é a mensagem, e o que precisa mudar?

Ver resposta

✓ Resposta: No golang-migrate, cada driver de banco se registra com um esquema de URL próprio, e é o esquema do DSN que escolhe o driver. O driver database/pgx/v5 se registra como pgx5; o esquema postgres pertence a outro driver, o database/postgres, baseado no lib/pq, que não foi importado. Num teste com a versão 4.20, o migrate.New com postgres:// devolveu failed to open database: database driver: unknown driver postgres (forgotten import?), e o mesmo endereço com pgx5:// aplicou a migração. A correção é trocar só o esquema na string passada ao migrator e manter o postgres:// no pool do pgx, que não aceita o pgx5. O -tags 'postgres' que às vezes aparece nas instruções também não ajuda aqui: essa tag serve para compilar a ferramenta de linha de comando com o driver embutido, e não tem efeito sobre a biblioteca.

Exercício 2

Um tratarErroPG devolvia fmt.Errorf("tabela não existe: %s", pgErr.TableName) para o código 42P01. Quando uma migração falhou em produção, o log mostrou só tabela não existe: , sem nome. Os casos de 23505 e 23502, porém, mostram a restrição e a coluna. Por que a diferença?

Ver resposta

✓ Resposta: Os campos TableName, ColumnName e ConstraintName do pgconn.PgError são preenchidos pelo próprio servidor, e só nos erros em que ele os informa. As violações de restrição acontecem quando a tabela já foi resolvida, e o PostgreSQL manda os detalhes: num teste, o 23505 trouxe usuarios_email_key e o 23502 trouxe a coluna nome. O 42P01 acontece antes, na análise da consulta, quando a relação nem foi encontrada, e o campo da tabela chega vazio; o nome aparece apenas no texto, em relation "auditoria" does not exist. Por isso o artigo usa pgErr.Message nesse caso. A lição geral é não confiar num campo de diagnóstico sem ter visto o erro real, e incluir no log a mensagem e o código, que estão sempre presentes.

Exercício 3

Uma tela de listagem precisa só do nome dos usuários, e alguém troca a consulta de buscarPorMetadata para SELECT id, nome FROM usuarios, mantendo pgx.RowToStructByName[Usuario]. A função passa a falhar em todas as chamadas. Qual é o erro, e quais são as saídas?

Ver resposta

✓ Resposta: O RowToStructByName exige que cada campo exportado da struct tenha uma coluna correspondente no resultado. Num teste com o pgx 5.11, a consulta parcial devolveu cannot find field Email in returned row, e uma struct com um campo a mais, sem coluna, falhou da mesma forma. A correspondência dispensa a tag db quando o nome bate sem diferenciar maiúsculas nem sublinhados: CriadoEm encontrou a coluna criado_em. As saídas são três: usar pgx.RowToStructByNameLax, que aceita colunas ausentes e deixa os campos com o valor zero; criar uma struct menor para a listagem, com só os campos usados; ou marcar com db:"-" os campos que nunca vêm do banco. A versão estrita é o padrão por um bom motivo: uma coluna esquecida no SELECT aparece na hora, em vez de virar um campo vazio que ninguém nota.

Exercício 4

Uma busca no blog usava to_tsquery('portuguese', @termo) e funcionava nos testes, feitos com uma palavra só. Em produção, a pesquisa canais e goroutines devolve erro em vez de resultados. O que aconteceu, e por que websearch_to_tsquery resolve?

Ver resposta

✓ Resposta: O to_tsquery espera uma expressão na sintaxe própria do PostgreSQL, com os operadores explícitos: canais & goroutines. Palavras separadas só por espaço não formam uma expressão válida, e num teste a consulta devolveu syntax error in tsquery: "canais e goroutines", com o código 42601. Com uma palavra só, a expressão é válida, por isso o defeito escapou dos testes. O websearch_to_tsquery, usado no artigo, interpreta o texto como um buscador da web: espaço vira E, -palavra vira negação, aspas formam frase, e uma entrada malformada não gera erro. No mesmo teste, canais -java virou 'can' & !'jav'. Esse resultado mostra também o stemmer em português: canais foi reduzido a can, e canal ficou canal, de modo que uma busca por canal não encontra um texto que só diz canais.

Exercício 5

Com o atualizarMetadata do artigo, um usuário tem {"notificacoes": {"email": true, "push": true}}. A tela de preferências envia uma ConfiguracaoUsuario com Notificacoes igual a {"push": false}, e a preferência de e-mail some. E, antes do COALESCE, usuários sem metadata nenhuma continuavam sem metadata depois de salvar. Explique os dois casos.

Ver resposta

✓ Resposta: O operador || do JSONB junta só o primeiro nível: quando as duas partes têm a mesma chave, o valor da direita substitui o da esquerda inteiro, mesmo que seja um objeto. Num teste, {"a":{"x":1}} || {"a":{"y":2}} resultou em {"a": {"y": 2}}. Por isso o mapa notificacoes enviado substitui o que estava gravado, e o email desaparece. As saídas são enviar sempre o objeto completo de notificações ou atualizar a chave aninhada com jsonb_set(metadata, '{notificacoes,push}', 'false'). O segundo caso vem do NULL: em SQL, quase toda operação com NULL dá NULL, e o || segue a regra; na mesma medição, NULL::jsonb || '{"tema":"escuro"}' deu NULL. O UPDATE rodava sem erro e gravava NULL de novo. O COALESCE(metadata, '{}'::jsonb) do artigo troca o NULL por um objeto vazio antes da junção.

Comentários

Mais em Golang

Structs: definição, inicialização e campos embutidos
Structs: definição, inicialização e campos embutidos

Structs em Go: literais com nome e posicionais, valor zero, ponteiros, structs…

Go com MongoDB: operações CRUD com o driver oficial
Go com MongoDB: operações CRUD com o driver oficial

Go com MongoDB pelo driver oficial v2: bson.ObjectID no lugar do primitive da…

APIs RESTful completas: projeto do zero ao deploy
APIs RESTful completas: projeto do zero ao deploy

API RESTful completa em Go, do zero ao deploy: domínio, migrações, repositório…