Resposta rápida: use pgx.Batch quando sua aplicação Go precisa executar várias queries no PostgreSQL e o custo de fazer uma viagem de rede por comando começou a importar. Você enfileira operações com Queue, envia o conjunto com SendBatch e consome cada resultado — Exec, Query ou QueryRow — na mesma ordem. Se todas as mudanças precisam confirmar ou falhar juntas, execute o batch dentro de uma transação explícita. Para milhares de inserts iguais, CopyFrom costuma ser uma escolha melhor.
Uma API frequentemente precisa gravar um pedido, atualizar estoque, registrar auditoria e buscar o estado final. Executar quatro chamadas separadas funciona, mas cada uma adiciona um round trip entre aplicação e banco. Em uma rede local rápida, o custo pode parecer pequeno; em produção, com concorrência e banco remoto, ele se acumula.
O pgx.Batch, disponível no driver pgx v5, permite agrupar esses comandos sem transformar tudo em uma string SQL improvisada. Neste guia, você vai aprender o fluxo correto, como combinar resultados diferentes, quando abrir transação, quais erros bloqueiam a conexão e como decidir entre Batch, CopyFrom e SQL set-based.
O que pgx.Batch resolve
Considere um handler que precisa executar três operações independentes:
_, err := pool.Exec(ctx, "UPDATE accounts SET last_seen_at = now() WHERE id = $1", accountID)
// trata erro
_, err = pool.Exec(ctx, "INSERT INTO audit_events (account_id, action) VALUES ($1, $2)", accountID, "login")
// trata erro
err = pool.QueryRow(ctx, "SELECT plan FROM accounts WHERE id = $1", accountID).Scan(&plan)
// trata erro
São três interações completas com o servidor. Com Batch, a aplicação prepara a sequência e a envia de uma vez:
batch := &pgx.Batch{}
batch.Queue(
"UPDATE accounts SET last_seen_at = now() WHERE id = $1",
accountID,
)
batch.Queue(
"INSERT INTO audit_events (account_id, action) VALUES ($1, $2)",
accountID,
"login",
)
batch.Queue(
"SELECT plan FROM accounts WHERE id = $1",
accountID,
)
results := pool.SendBatch(ctx, batch)
Isso reduz a conversa entre cliente e servidor, mas não remove a responsabilidade de ler os resultados. O protocolo continua produzindo uma resposta para cada comando, e o pgx precisa saber qual resposta corresponde a qual operação.
O ganho mais previsível é reduzir latência de rede, não tornar a query individual magicamente mais rápida. Se o banco gasta dois segundos em uma consulta ruim, Batch não corrige índice ausente, lock ou plano de execução inadequado.
Exemplo completo com Exec e QueryRow
O padrão mais seguro é encapsular a operação em uma função pequena e consumir os resultados na ordem exata do Queue:
package account
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type LoginResult struct {
Plan string
}
func RegisterLogin(
ctx context.Context,
pool *pgxpool.Pool,
accountID string,
) (LoginResult, error) {
batch := &pgx.Batch{}
batch.Queue(
"UPDATE accounts SET last_seen_at = now() WHERE id = $1",
accountID,
)
batch.Queue(
"INSERT INTO audit_events (account_id, action) VALUES ($1, $2)",
accountID,
"login",
)
batch.Queue(
"SELECT plan FROM accounts WHERE id = $1",
accountID,
)
br := pool.SendBatch(ctx, batch)
defer br.Close()
tag, err := br.Exec()
if err != nil {
return LoginResult{}, fmt.Errorf("atualizar last_seen: %w", err)
}
if tag.RowsAffected() != 1 {
return LoginResult{}, fmt.Errorf("conta não encontrada")
}
if _, err := br.Exec(); err != nil {
return LoginResult{}, fmt.Errorf("registrar auditoria: %w", err)
}
var result LoginResult
if err := br.QueryRow().Scan(&result.Plan); err != nil {
return LoginResult{}, fmt.Errorf("ler plano: %w", err)
}
if err := br.Close(); err != nil {
return LoginResult{}, fmt.Errorf("fechar batch: %w", err)
}
return result, nil
}
O exemplo faz uma chamada explícita a Close e também mantém defer br.Close() como proteção para retornos antecipados. A segunda chamada é segura para a interface, e a explícita permite verificar o erro antes de declarar sucesso.
Existe, porém, um problema de domínio: se o UPDATE funcionar e o INSERT falhar, talvez o sistema fique parcialmente alterado. Batch reduz round trips; ele não substitui atomicidade. Para garantir “tudo ou nada”, use transação.
Batch dentro de uma transação
Quando os comandos formam uma única operação de negócio, abra uma transação e use tx.SendBatch:
func CreateOrder(
ctx context.Context,
pool *pgxpool.Pool,
customerID string,
totalCents int64,
) (string, error) {
tx, err := pool.Begin(ctx)
if err != nil {
return "", fmt.Errorf("iniciar transação: %w", err)
}
defer tx.Rollback(ctx)
orderID := uuid.NewString()
batch := &pgx.Batch{}
batch.Queue(`
INSERT INTO orders (id, customer_id, total_cents, status)
VALUES ($1, $2, $3, 'pending')
`, orderID, customerID, totalCents)
batch.Queue(`
INSERT INTO order_events (order_id, event_type)
VALUES ($1, 'order_created')
`, orderID)
batch.Queue(`
UPDATE customers
SET order_count = order_count + 1
WHERE id = $1
`, customerID)
br := tx.SendBatch(ctx, batch)
for i, operation := range []string{
"criar pedido",
"registrar evento",
"atualizar cliente",
} {
if _, err := br.Exec(); err != nil {
br.Close()
return "", fmt.Errorf("%s (item %d): %w", operation, i, err)
}
}
if err := br.Close(); err != nil {
return "", fmt.Errorf("fechar batch: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return "", fmt.Errorf("confirmar transação: %w", err)
}
return orderID, nil
}
A ordem correta é importante:
- abrir a transação;
- criar e enviar o batch;
- consumir todos os resultados;
- fechar
BatchResultse verificar o erro; - executar
Commit.
Não confirme a transação antes de fechar o batch. Enquanto existem resultados pendentes, a conexão continua ocupada pelo protocolo. O defer tx.Rollback(ctx) protege os caminhos de erro; depois de um commit bem-sucedido, o rollback não desfaz a transação já encerrada.
Se você ainda não domina o ciclo de vida transacional, revise PostgreSQL com Go e o guia de transações, locks e retry.
Como usar Query para várias linhas
Um comando do lote pode retornar várias linhas. Nesse caso, chame Query, percorra o resultado e feche Rows antes de pedir o próximo item do batch:
batch := &pgx.Batch{}
batch.Queue(`
SELECT id, email
FROM users
WHERE status = $1
ORDER BY id
LIMIT 100
`, "pending")
batch.Queue(`
SELECT count(*)
FROM users
WHERE status = $1
`, "active")
br := pool.SendBatch(ctx, batch)
defer br.Close()
rows, err := br.Query()
if err != nil {
return err
}
for rows.Next() {
var id string
var email string
if err := rows.Scan(&id, &email); err != nil {
rows.Close()
return err
}
// processa id e email
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
var activeCount int64
if err := br.QueryRow().Scan(&activeCount); err != nil {
return err
}
if err := br.Close(); err != nil {
return err
}
Tentar chamar QueryRow do segundo comando enquanto o primeiro Rows ainda não foi consumido ou fechado deixa o fluxo ambíguo e pode produzir erros de conexão ocupada. A regra é simples: finalize um resultado antes de avançar para o próximo.
Também evite fazer processamento lento dentro do loop quando o batch mantém uma conexão do pool reservada. Se a transformação é cara, leia os dados para uma estrutura com tamanho limitado, feche o resultado e processe depois. Para controlar concorrência e capacidade do banco, veja pgxpool em produção.
A ordem dos resultados é parte do contrato
pgx.Batch não associa automaticamente um nome ao resultado. Se você enfileirar UPDATE, SELECT e INSERT, precisa consumir Exec, QueryRow e Exec nessa ordem.
Uma forma de reduzir enganos é manter Queue e leitura próximos, com comentários ou funções pequenas. Para lotes montados dinamicamente, registre o tipo esperado:
type batchOperation struct {
name string
read func(pgx.BatchResults) error
}
batch := &pgx.Batch{}
operations := make([]batchOperation, 0, 2)
batch.Queue("DELETE FROM sessions WHERE expires_at < now()")
operations = append(operations, batchOperation{
name: "remover sessões expiradas",
read: func(br pgx.BatchResults) error {
_, err := br.Exec()
return err
},
})
batch.Queue("SELECT count(*) FROM sessions")
operations = append(operations, batchOperation{
name: "contar sessões",
read: func(br pgx.BatchResults) error {
var count int64
return br.QueryRow().Scan(&count)
},
})
Essa abstração só vale a pena quando a composição realmente é dinâmica. Para três comandos fixos, código explícito costuma ser mais fácil de revisar.
Batch não é sinônimo de paralelismo
Enviar comandos juntos não significa que o PostgreSQL executará todos em paralelo. Em uma única conexão, os comandos seguem o protocolo e preservam a ordem necessária para os resultados.
Se as queries são independentes e realmente precisam rodar ao mesmo tempo, você pode usar conexões diferentes por meio do pool e concorrência limitada. Isso tem outro custo: mais conexões, mais carga simultânea, mais contenção e possibilidade de saturar o banco.
Escolha conforme o objetivo:
- reduzir round trips em uma sequência:
pgx.Batch; - executar trabalho independente em paralelo: goroutines com limite e pool dimensionado;
- alterar muitas linhas com uma regra comum: um comando SQL set-based;
- inserir muitas linhas na mesma tabela:
CopyFrom.
Não use goroutines apenas porque Go facilita concorrência. Banco de dados é um recurso compartilhado; aumentar paralelismo sem medir pode piorar latência para todos. O guia de worker pool e backpressure explica como impor limites.
pgx.Batch, CopyFrom ou SQL único?
A escolha depende do formato do trabalho:
| Opção | Melhor uso | Ponto de atenção |
|---|---|---|
pgx.Batch | comandos diferentes ou resultados mistos | ordem dos resultados e fechamento obrigatório |
CopyFrom | muitas linhas para a mesma tabela | fluxo especializado, não mistura SELECT e UPDATE |
INSERT ... VALUES (...), (...) | lote pequeno e homogêneo | quantidade de parâmetros e geração do SQL |
unnest com arrays | operação set-based parametrizada | exige modelar arrays e SQL mais específico |
| CTE | encadear mudanças e retornos no servidor | pode ficar difícil de manter se concentrar lógica demais |
| chamadas separadas | poucos comandos fora de caminho crítico | mais round trips, porém código simples |
Para importar 100 mil registros, não crie um Batch com 100 mil INSERTs sem testar memória, tamanho da mensagem e tempo de retenção da conexão. CopyFrom foi desenhado para esse cenário.
Para atualizar vários IDs com a mesma regra, um único comando pode ser superior:
UPDATE jobs
SET status = 'archived'
WHERE id = ANY($1::uuid[]);
O melhor round trip é aquele que você não precisa fazer. Antes de adotar Batch, pergunte se o banco consegue resolver a operação como conjunto.
Tamanho do lote e backpressure
Não existe um número universal de comandos por batch. O limite saudável depende de:
- tamanho dos parâmetros;
- custo das queries;
- tempo de transação;
- quantidade de conexões do pool;
- latência da rede;
- concorrência da aplicação;
- locks mantidos durante o lote;
- memória disponível no cliente e no servidor.
Comece com lotes pequenos e limitados, por exemplo 50 ou 100 operações, e meça. Em processamentos longos, quebre o trabalho em páginas:
const batchSize = 100
for start := 0; start < len(items); start += batchSize {
end := min(start+batchSize, len(items))
if err := persistBatch(ctx, pool, items[start:end]); err != nil {
return err
}
}
Um batch gigantesco mantém uma conexão ocupada por mais tempo, amplia o impacto de rollback e dificulta descobrir qual item falhou. Dividir o trabalho cria pontos de progresso e reduz a unidade de repetição, mas muda a atomicidade. Se tudo precisa ser indivisível, talvez o desenho do job precise ser revisto.
Erros comuns com SendBatch
Esquecer de fechar BatchResults
Enquanto BatchResults não é fechado, a conexão pode permanecer indisponível para o pool. Sob carga, isso aparece como espera crescente por conexão, mesmo quando o PostgreSQL não está no limite.
Sempre chame Close e trate seu erro.
Consumir menos resultados do que comandos
Se cinco comandos foram enfileirados e o código lê apenas três, os resultados restantes ainda precisam ser processados ou descartados pelo fechamento. Um erro inicial também pode afetar comandos e resultados posteriores. Não tente continuar como se o lote parcial fosse confiável sem entender o estado da transação.
Ler os resultados na ordem errada
Chamar QueryRow para um comando que era UPDATE, ou Exec para um SELECT esperado, embaralha o contrato. Mantenha a sequência explícita e coberta por teste de integração.
Assumir atomicidade sem transação
Batch é otimização de protocolo. A regra de negócio “tudo ou nada” pertence à transação.
Criar lote sem limite a partir de input externo
Nunca transforme diretamente uma lista enviada pelo usuário em milhões de comandos enfileirados. Valide quantidade e tamanho, aplique paginação e proteja o banco com timeout.
Ignorar cancelamento
Use um context.Context com deadline coerente. Se o request for cancelado ou o banco exceder o tempo permitido, a operação deve retornar e liberar recursos:
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()
br := pool.SendBatch(ctx, batch)
Timeout não substitui otimização de query; ele limita o dano quando algo demora mais que o aceitável. Para padrões completos, veja context com timeout e cancelamento.
Como testar código com pgx.Batch
Mocks de driver podem confirmar chamadas, mas não reproduzem protocolo, constraints, transações e comportamento real do PostgreSQL. Para código de persistência, prefira teste de integração com uma instância descartável.
Um bom teste deve cobrir:
- caminho de sucesso;
- falha no segundo ou terceiro comando;
- rollback quando o batch está dentro de transação;
- ordem e tipos dos resultados;
- constraint única ou foreign key;
- cancelamento por contexto;
- conexão reutilizável depois do fechamento.
Com Testcontainers em Go, o teste pode subir PostgreSQL, aplicar migrations, executar a função e consultar o estado final. Valide o efeito no banco, não apenas se err == nil.
Para comparar desempenho, escreva um benchmark ou teste de carga que represente sua rede e suas queries. Um benchmark local com PostgreSQL no mesmo processo de CI pode mostrar alocação e overhead do cliente, mas subestimar o ganho de reduzir round trips em uma rede real.
Observabilidade e métricas
Batch pode melhorar latência e também esconder onde o tempo foi gasto se você medir apenas a operação inteira. Registre informações suficientes para diagnóstico, sem colocar SQL completo ou dados sensíveis nos logs.
Métricas úteis:
- duração do batch;
- quantidade de comandos;
- resultado geral: sucesso, erro ou cancelamento;
- tempo de espera por conexão no pool;
- número de conexões adquiridas e ociosas;
- duração da transação;
- tamanho lógico do lote, como número de itens.
Evite usar ID de usuário, pedido ou query completa como label de Prometheus. Isso cria alta cardinalidade. Para tracing, um span do batch pode ter atributos limitados como db.operation=batch, batch.size=50 e o nome estável do caso de uso.
Combine com OpenTelemetry em Go e slog para logging estruturado.
Checklist para usar pgx.Batch em produção
- Existe mais de uma operação e o round trip é relevante.
- Um único comando SQL não resolveria melhor.
-
CopyFromnão é mais adequado para o volume de inserts. - O tamanho do batch possui limite explícito.
- Os resultados são consumidos na ordem de
Queue. - Cada
Rowsé completamente lido ou fechado antes do próximo resultado. -
BatchResults.Closeé sempre chamado e seu erro é verificado. - Operações atômicas usam transação explícita.
- O contexto possui deadline coerente.
- O código não mantém conexão enquanto faz processamento lento desnecessário.
- Testes de integração cobrem rollback e constraints.
- Métricas observam duração, tamanho do lote e espera no pool.
- O ganho foi medido com carga representativa.
Perguntas frequentes
O que é pgx.Batch?
É uma fila de comandos SQL enviada ao PostgreSQL com SendBatch. A aplicação reduz round trips e consome os resultados com Exec, Query ou QueryRow na mesma ordem dos comandos enfileirados.
Batch já cria uma transação?
Não trate o recurso como garantia automática de atomicidade. Quando todas as mudanças precisam confirmar juntas, abra uma transação, use tx.SendBatch, feche os resultados e só então execute Commit.
Quando usar CopyFrom em vez de Batch?
Use CopyFrom para inserir muitas linhas com o mesmo formato em uma tabela. Use Batch quando há comandos diferentes, parâmetros variados ou uma combinação de escrita e leitura.
Por que Close pode retornar erro?
Alguns erros do servidor ou do protocolo podem aparecer apenas enquanto os resultados restantes são processados e a conexão é liberada. Por isso, não ignore o retorno de Close, principalmente antes de fazer commit.
Batch executa queries em paralelo?
Não. Ele reduz viagens de rede ao agrupar comandos em uma conexão. Paralelismo exige conexões e execução concorrente separadas, o que aumenta carga sobre o banco e precisa de limites.
Conclusão
pgx.Batch é uma ferramenta útil quando várias operações SQL formam uma sequência curta e o custo de rede importa. O uso correto exige disciplina: fila limitada, resultados na ordem, Rows fechados, BatchResults.Close verificado e transação explícita quando a regra de negócio pede atomicidade.
Não escolha Batch apenas porque “menos chamadas” parece automaticamente melhor. Compare com SQL set-based e CopyFrom, meça latência, observe o pool e mantenha o código legível. Em muitos serviços, agrupar três a dez comandos frequentes já reduz uma parte relevante do tempo de banco sem introduzir paralelismo perigoso.
Para continuar, leia PostgreSQL com Go, pgxpool em produção, transações e locks e testes de integração com Testcontainers.