← Voltar para o blog

pgx.Batch em Go: Múltiplas Queries com PostgreSQL

Aprenda a usar pgx.Batch em Go para enviar várias queries ao PostgreSQL com menos round trips: Exec, Query, QueryRow, transações, erros e testes.

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 QueryRowna 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:

  1. abrir a transação;
  2. criar e enviar o batch;
  3. consumir todos os resultados;
  4. fechar BatchResults e verificar o erro;
  5. 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çãoMelhor usoPonto de atenção
pgx.Batchcomandos diferentes ou resultados mistosordem dos resultados e fechamento obrigatório
CopyFrommuitas linhas para a mesma tabelafluxo especializado, não mistura SELECT e UPDATE
INSERT ... VALUES (...), (...)lote pequeno e homogêneoquantidade de parâmetros e geração do SQL
unnest com arraysoperação set-based parametrizadaexige modelar arrays e SQL mais específico
CTEencadear mudanças e retornos no servidorpode ficar difícil de manter se concentrar lógica demais
chamadas separadaspoucos comandos fora de caminho críticomais 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:

  1. caminho de sucesso;
  2. falha no segundo ou terceiro comando;
  3. rollback quando o batch está dentro de transação;
  4. ordem e tipos dos resultados;
  5. constraint única ou foreign key;
  6. cancelamento por contexto;
  7. 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.
  • CopyFrom nã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.

Fontes