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
-
pgx — GitHub — Repositório oficial com documentação completa. https://github.com/jackc/pgx
-
Documentação pgxpool — Configuração e uso do pool de conexões. https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool
-
Documentação do PostgreSQL — Referência completa, na versão atual. https://www.postgresql.org/docs/current/
-
golang-migrate — Ferramenta de migrações compatível com PostgreSQL. https://github.com/golang-migrate/migrate
-
PostGIS — Extensão geoespacial para PostgreSQL. https://postgis.net
-
pgvector — Extensão para busca vetorial com embeddings de AI. https://github.com/pgvector/pgvector
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.