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, graceful shutdown em Go, idempotência com retry e DLQ e slog para logging estruturado.
Por que agregar erros muda a qualidade do sistema
O comportamento padrão em Go é retornar no primeiro erro:
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:
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:
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:
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:
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:
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:
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 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:
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.
O chamador decide o que fazer com o erro agregado. Um job noturno pode registrar tudo via slog 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:
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.
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:
// 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:
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 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:
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
Closepodem falhar. - Operações paralelas com
errgrouponde 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 antes de agregar erros dentro de uma unidade de trabalho transacional.
Boas práticas resumidas
- Use
errors.Joinquando o chamador se beneficia de ver todas as falhas, não apenas a primeira. - Adeque contexto a cada erro (
fmt.Errorfcom%w) antes de agregá-lo, para preservar a trilha de onde falhou. - Confie em
errors.Iseerrors.Aspara inspecionar agregados — eles atravessamUnwrap() []errorautomaticamente. - Para converter erros agregados em estrutura (JSON por campo, lista de códigos), itere manualmente sobre
Unwrap() []error. - Combine com
context.Contextpara 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 Python Dev Brasil aborda tratamento de erros em serviços backend.