---
title: "errors.Join em Go: Agregando Múltiplos Erros em Produção"
url: "https://golang.com.br/blog/errors-join-go-multiplos-erros-producao/"
markdown_url: "https://golang.com.br/blog/errors-join-go-multiplos-erros-producao.MD"
description: "Aprenda errors.Join em Go para agregar múltiplos erros: multi-error pattern, errors.Is/As com Join, validação batch, coleta de falhas em pipelines e boas práticas de logging estruturado."
date: "2026-06-10"
author: "Golang Brasil"
---

# errors.Join em Go: Agregando Múltiplos Erros em Produção

Aprenda errors.Join em Go para agregar múltiplos erros: multi-error pattern, errors.Is/As com Join, validação batch, coleta de falhas em pipelines e boas práticas de logging estruturado.


Validar um formulário e parar no primeiro erro é frustrante para quem consome a API. Rodar um pipeline de ingestão e abortar na primeira falha esconde dezenas de problemas que iam aparecer um a um nas próximas execuções. Fechar vários recursos e registrar só o último erro disfarça a causa original. Em todos esses casos, o problema não é o erro individual, mas a falta de uma forma limpa de juntar vários erros em um só valor.

A partir do Go 1.20, a biblioteca padrão oferece `errors.Join` para exatamente isso. Este guia mostra como usar `errors.Join` e padrões de multi-error em serviços reais, complementando o [guia definitivo de tratamento de erros](/aprenda/golang-erros/), [graceful shutdown em Go](/blog/graceful-shutdown-go-producao/), [idempotência com retry e DLQ](/blog/idempotencia-retry-dlq-go/) e [slog para logging estruturado](/blog/slog-go-logging-estruturado/).

## Por que agregar erros muda a qualidade do sistema

O comportamento padrão em Go é retornar no primeiro erro:

```go
func processarPedido(p Pedido) error {
    if err := validarEstoque(p); err != nil {
        return err
    }
    if err := reservarPagamento(p); err != nil {
        return err
    }
    if err := emitirNota(p); err != nil {
        return err
    }
    return nil
}
```

Esse padrão é seguro e aparece em todo código Go. Ele só passa a atrapalhar quando o chamador se beneficiaria de ver todas as falhas de uma vez. Pense em uma API que recebe um JSON com cinco campos inválidos: retornar só o primeiro obriga o cliente a fazer cinco round-trips para descobrir todos os problemas. Pense em um job noturno que processa mil registros: abortar no primeiro registro ruim adia a descoberta dos outros 999 problemas.

`errors.Join` resolve isso sem inventar uma estrutura nova. Ele recebe zero ou mais erros e devolve um único erro que representa todos os não-nil.

## O básico do errors.Join

A assinatura é deliberadamente simples:

```go
func Join(errs ...error) error
```

Se todos os argumentos forem `nil`, o retorno é `nil`. Se pelo menos um for não-nil, o retorno é um erro cujo método `Error()` concatena as mensagens separadas por nova linha:

```go
err1 := errors.New("nome é obrigatório")
err2 := errors.New("email inválido")
err3 := errors.New("cpf com formato errado")

err := errors.Join(err1, err2, err3)
fmt.Println(err)
// nome é obrigatório
// email inválido
// cpf com formato errado
```

O detalhe importante é que `errors.Join` ignora `nil` automaticamente. Você não precisa filtrar antes:

```go
err := errors.Join(
    validarNome(input.Nome),       // pode retornar nil
    validarEmail(input.Email),     // pode retornar nil
    validarCPF(input.CPF),         // pode retornar nil
    validarEndereco(input.End),    // pode retornar nil
)
if err != nil {
    return err
}
```

Se nenhuma validação falhar, `err` é `nil` e o fluxo continua. Esse é o multi-error pattern idiomático em Go moderno.

## errors.Is e errors.As funcionam com Join

A parte que costuma surpreender é que o erro retornado por `errors.Join` implementa a interface `Unwrap() []error`. Isso significa que `errors.Is` e `errors.As` atravessam todos os erros agregados, não só o primeiro:

```go
var ErrEstoqueInsuficiente = errors.New("estoque insuficiente")

func main() {
    err := errors.Join(
        ErrEstoqueInsuficiente,
        fmt.Errorf("pagamento recusado: %w", ErrCartaoRecusado),
    )

    if errors.Is(err, ErrEstoqueInsuficiente) {
        fmt.Println("há problema de estoque")
    }
    if errors.Is(err, ErrCartaoRecusado) {
        fmt.Println("há problema de pagamento")
    }
}
```

Os dois `errors.Is` retornam verdadeiro. Isso é fundamental: o chamador não precisa saber que o erro veio de um `Join`. Ele continua inspecionando com sentinelas e tipos como faz com qualquer erro envelopado. O mesmo vale para `errors.As` quando você usa erros tipados carregando metadados como código de status HTTP ou ID de correlação.

## Validação batch: o caso de uso mais comum

O cenário onde `errors.Join` brilha é validação que deve coletar todas as falhas antes de responder. Em vez de encadear `if err != nil { return err }`, acumule:

```go
func validarUsuario(u UsuarioRequest) error {
    var errs []error

    if strings.TrimSpace(u.Nome) == "" {
        errs = append(errs, errors.New("nome é obrigatório"))
    }
    if !emailValido(u.Email) {
        errs = append(errs, fmt.Errorf("email inválido: %s", u.Email))
    }
    if u.Idade < 0 || u.Idade > 130 {
        errs = append(errs, fmt.Errorf("idade fora do intervalo: %d", u.Idade))
    }
    if u.Senha != "" && len(u.Senha) < 8 {
        errs = append(errs, errors.New("senha deve ter ao menos 8 caracteres"))
    }

    return errors.Join(errs...)
}
```

O handler HTTP chama uma vez e devolve a lista inteira para o cliente:

```go
if err := validarUsuario(input); err != nil {
    respondWithError(w, http.StatusBadRequest, err)
    return
}
```

A diferença para o usuário da API é enorme: em vez de cinco chamadas para descobrir cinco erros, uma chamada revela todos. Em APIs públicas, isso reduz carga no backend e melhora a experiência de integração. Combine com [OpenAPI para documentar o contrato](/blog/openapi-go-oapi-codegen-contratos/) e os clientes saberão exatamente o que esperar.

## Coleta de falhas em pipelines e workers

Jobs que processam múltiplos itens independentes costumam se beneficiar de continuar processando mesmo quando um item falha. O padrão é acumular erros e registrar o contexto de cada um:

```go
func importarRegistros(ctx context.Context, regs []Registro) error {
    var errs []error

    for i, r := range regs {
        if err := ctx.Err(); err != nil {
            return fmt.Errorf("importação cancelada no item %d: %w", i, err)
        }
        if err := importarUm(ctx, r); err != nil {
            errs = append(errs, fmt.Errorf("item %d (%s): %w", i, r.ID, err))
        }
    }

    return errors.Join(errs...)
}
```

Duas decisões importantes nesse padrão. Primeiro, o erro de cada item recebe contexto (índice e ID) via `fmt.Errorf` com `%w` antes de entrar na lista, então o log final mostra exatamente quais itens falharam e por quê. Segundo, o cancelamento de contexto é tratado à parte e retorna imediatamente, porque um job cancelado não deve continuar processando — esse comportamento está alinhado com o que discutimos em [context.Context com timeout e cancelamento](/blog/context-timeout-cancelamento-go/).

O chamador decide o que fazer com o erro agregado. Um job noturno pode registrar tudo via [slog](/blog/slog-go-logging-estruturado/) e seguir; uma API síncrona pode retornar 207 Multi-Status ou um 422 com a lista detalhada.

## Erros tipados agregados com errors.As

Quando cada erro carrega dados estruturados, `errors.As` percorre o agregado e captura a primeira ocorrência do tipo. Para coletar todas as ocorrências, você precisa iterar manualmente sobre `Unwrap() []error`:

```go
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

func colectValidationErrors(err error) []*ValidationError {
    var out []*ValidationError

    var target *ValidationError
    if errors.As(err, &target) {
        out = append(out, target)
    }

    // Percorre erros agregados por Join
    type joinUnwrapper interface{ Unwrap() []error }
    var uw joinUnwrapper
    if errors.As(err, &uw) {
        for _, e := range uw.Unwrap() {
            var t *ValidationError
            if errors.As(e, &t) {
                out = append(out, t)
            }
        }
    }

    return out
}
```

Esse padrão aparece quando você quer converter o erro agregado em uma resposta JSON estruturada por campo, em vez de uma lista de strings. É mais trabalhoso, mas mantém a fronteira entre domínio e transporte — o mesmo princípio de separar DTO de entidade que vale para [JSON em APIs Go](/blog/json-go-encoding-json-v2-streaming-validacao/).

## Fechamento de múltiplos recursos

Outro caso clássico é fechar vários recursos e não mascarar o primeiro erro ao fechar o segundo. O padrão `defer` ingênuo perde erros:

```go
// Problemático: só o último Close sobrevive
defer f1.Close()
defer f2.Close()
defer f3.Close()
```

Se `f1.Close()` falhar, você nunca saberá, porque `f2.Close()` roda depois e o `defer` só propaga o último. Com `errors.Join` você coleta todos:

```go
func processarArquivos(caminhos []string) (errRet error) {
    var arquivos []*os.File
    defer func() {
        var errs []error
        for _, f := range arquivos {
            if err := f.Close(); err != nil {
                errs = append(errs, fmt.Errorf("ao fechar %s: %w", f.Name(), err))
            }
        }
        errRet = errors.Join(errRet, errors.Join(errs...))
    }()

    for _, c := range caminhos {
        f, err := os.Open(c)
        if err != nil {
            return fmt.Errorf("ao abrir %s: %w", c, err)
        }
        arquivos = append(arquivos, f)
        // ... processa
    }
    return nil
}
```

A linha-chave é `errors.Join(errRet, errors.Join(errs...))`: ela combina o erro principal do processamento com quaisquer erros de fechamento, sem que um esconda o outro. Esse padrão é especialmente útil em [shutdown gracioso](/blog/graceful-shutdown-go-producao/) onde você precisa fechar conexões de banco, filas e HTTP servers e registrar todas as falhas.

## logging estruturado de erros agregados

Quando o erro agregado chega ao ponto de logging, registre-o de forma que preserve a estrutura. Com `slog`:

```go
if err := importarRegistros(ctx, regs); err != nil {
    slog.Error("importação concluída com falhas",
        "err", err,
        "total_registros", len(regs),
    )
}
```

O método `Error()` do erro agregado concatena com nova linha, o que é legível em logs texto. Se você quiser extrair os erros individuais para um campo estruturado, use o mesmo padrão de iterar sobre `Unwrap() []error` mostrado acima. O importante é não registrar só `err.Error()` quando o contexto agregado importa — o número de sub-erros é informação tão valiosa quanto as mensagens.

## Quando NÃO usar errors.Join

`errors.Join` não é substituto universal de `return err`. Os casos onde agregar faz sentido são específicos:

- **Validação** onde o chamador quer ver todas as falhas.
- **Pipelines e workers** onde itens são independentes e vale a pena continuar.
- **Fechamento de recursos** onde múltiplos `Close` podem falhar.
- **Operações paralelas** com `errgroup` onde você quer a árvore inteira de falhas.

Nos demais casos, o padrão `if err != nil { return err }` continua sendo o correto. Agregar erros onde não há benefício só adiciona ruído e dificulta o tratamento específico no chamador. Em particular, dentro de uma transação de banco de dados, abortar no primeiro erro é quase sempre o comportamento desejado — retomar com erros acumulados não faz sentido quando você já vai fazer rollback. Releia [transações PostgreSQL com locks e retry](/blog/postgresql-transacoes-go-locks-retry/) antes de agregar erros dentro de uma unidade de trabalho transacional.

## Boas práticas resumidas

- Use `errors.Join` quando o chamador se beneficia de ver todas as falhas, não apenas a primeira.
- Adeque contexto a cada erro (`fmt.Errorf` com `%w`) antes de agregá-lo, para preservar a trilha de onde falhou.
- Confie em `errors.Is` e `errors.As` para inspecionar agregados — eles atravessam `Unwrap() []error` automaticamente.
- Para converter erros agregados em estrutura (JSON por campo, lista de códigos), itere manualmente sobre `Unwrap() []error`.
- Combine com `context.Context` para cancelamento: erros de cancelamento retornam imediatamente, erros de domínio são acumulados.
- Não agregue dentro de transações onde o rollback exige abortar no primeiro erro.
- Registre erros agregados com logging estruturado, preservando o número de sub-erros e o contexto de cada um.

Erros agregados bem usados transformam APIs frustrantes em APIs informativas e jobs opacos em jobs depuráveis. A biblioteca padrão já entrega a ferramenta; o trabalho é decidir onde coletar faz sentido e onde retornar cedo continua sendo a escolha certa. Frameworks mudam, mas os padrões de contrato de erro são os mesmos — se você trabalha em múltiplas stacks, vale comparar com como o <a href="https://python.dev.br/blog/golang-erros/" target="_blank" rel="noopener noreferrer" onclick="umami.track(portfolio-site-click, { destination: python.dev.br })">Python Dev Brasil aborda tratamento de erros</a> em serviços backend.
