---
title: "JSON em Go: encoding/json, v2, Streaming e Validação"
url: "https://golang.com.br/blog/json-go-encoding-json-v2-streaming-validacao/"
markdown_url: "https://golang.com.br/blog/json-go-encoding-json-v2-streaming-validacao.MD"
description: "Aprenda JSON em Go para APIs de produção: tags, Decoder, Encoder, streaming, validação, json/v2, erros comuns, segurança e performance."
date: "2026-06-15"
author: "Golang Brasil"
---

# JSON em Go: encoding/json, v2, Streaming e Validação

Aprenda JSON em Go para APIs de produção: tags, Decoder, Encoder, streaming, validação, json/v2, erros comuns, segurança e performance.


JSON em Go parece simples no primeiro contato: cria uma `struct`, coloca algumas tags, chama `json.Marshal` ou `json.Unmarshal` e pronto. Em APIs reais, porém, os detalhes aparecem rápido. Um campo opcional vira `null` quando deveria sumir. Um número grande perde precisão. Um payload desconhecido passa silenciosamente. Um endpoint aceita corpo gigante demais. Um `map[string]any` espalha type assertions pelo código. Um decoder lê o primeiro objeto e ignora lixo depois dele.

A boa notícia é que a biblioteca padrão já cobre grande parte do trabalho quando usada com disciplina. O pacote `encoding/json` é estável, conhecido, suficiente para a maioria das APIs e fácil de combinar com validação, logs, testes e contratos. Ao mesmo tempo, o ecossistema Go acompanha experimentos como `encoding/json/v2`, que apontam para APIs mais explícitas e correções de comportamento acumuladas ao longo dos anos.

Este guia mostra como trabalhar com JSON em Go sem transformar handlers em código frágil. Ele complementa [APIs REST em Go](/aprenda/api-rest-go/), [autenticação e autorização em Go](/blog/autenticacao-autorizacao-go-apis/), [OpenAPI com oapi-codegen](/blog/openapi-go-oapi-codegen-contratos/), [testes de integração com Testcontainers](/blog/go-testcontainers-testes-integracao-containers/) e [webhooks com assinatura e idempotência](/blog/webhooks-go-assinatura-idempotencia/).

## O modelo mental: JSON é borda, não domínio

O erro mais comum é deixar o formato JSON invadir o domínio inteiro da aplicação. JSON é uma representação de entrada e saída. Ele conversa com HTTP, clientes externos, filas e arquivos. Dentro do domínio, prefira tipos explícitos, invariantes claras e nomes que façam sentido para o negócio.

Um padrão simples é separar DTO de domínio:

```go
type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   *int   `json:"age,omitempty"`
}

type User struct {
    ID    UserID
    Name  string
    Email Email
    Age   *int
}
```

A struct de request conhece tags JSON, campos opcionais e pequenas diferenças de contrato. A entidade de domínio conhece regras reais. Essa separação evita que decisões de API, como `omitempty` ou nomes em snake case, virem acoplamento em todo o sistema.

Em projetos pequenos, usar a mesma struct para tudo pode ser aceitável. Em projetos que têm versão de API, fila, banco e tela administrativa, a separação paga rápido.

## Tags JSON sem surpresa

Tags são o contrato mais visível entre Go e o cliente:

```go
type ProductResponse struct {
    ID          string  `json:"id"`
    Name        string  `json:"name"`
    PriceCents  int64   `json:"price_cents"`
    Description *string `json:"description,omitempty"`
    InternalSKU string  `json:"-"`
}
```

Algumas regras práticas:

- Use nomes consistentes, normalmente `snake_case` em APIs públicas brasileiras e internacionais.
- Use `json:"-"` para impedir vazamento de campo interno.
- Use ponteiro quando precisar distinguir "campo ausente" de "valor zero".
- Use `omitempty` quando ausência for melhor que `null` para o contrato.
- Evite depender de campo exportado sem tag em API pública.

O ponto do ponteiro é importante. `int` com valor zero pode significar "cliente enviou 0" ou "cliente não enviou nada". `*int` permite distinguir:

```go
if req.Age == nil {
    // ausente: talvez manter valor atual
} else if *req.Age < 0 {
    // presente, mas inválido
}
```

Para PATCH, ponteiros ou tipos opcionais explícitos costumam ser melhores que uma struct cheia de valores zero ambíguos.

## Marshal e Unmarshal: bons para casos fechados

`json.Marshal` e `json.Unmarshal` são ótimos quando você já tem o conteúdo inteiro em memória e o payload é pequeno ou médio:

```go
func encodeUser(w http.ResponseWriter, user UserResponse) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(http.StatusOK)

    if err := json.NewEncoder(w).Encode(user); err != nil {
        // em produção, registre o erro; talvez a conexão já tenha caído
        return
    }
}
```

Mesmo para resposta simples, prefira `json.NewEncoder(w).Encode(...)` no handler HTTP. Ele escreve direto no `ResponseWriter` e adiciona a quebra de linha final, que é aceitável para JSON e útil em logs e ferramentas de linha de comando.

Para entrada, `json.Unmarshal` exige ler o corpo inteiro antes. Isso pode ser suficiente em jobs internos, testes ou arquivos pequenos, mas em HTTP público o ideal é controlar tamanho e decoder.

## Decoder em HTTP: limite tamanho e rejeite lixo

Um handler robusto não deve aceitar corpo ilimitado nem JSON parcialmente válido. Use `http.MaxBytesReader`, `json.Decoder` e uma segunda leitura para garantir que não há tokens extras depois do objeto principal.

```go
func decodeJSON[T any](w http.ResponseWriter, r *http.Request, dst *T) error {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
    defer r.Body.Close()

    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()

    if err := dec.Decode(dst); err != nil {
        return fmt.Errorf("json inválido: %w", err)
    }

    if dec.Decode(&struct{}{}) != io.EOF {
        return errors.New("json deve conter apenas um objeto")
    }

    return nil
}
```

`DisallowUnknownFields` é útil em APIs internas e contratos mais rígidos, porque detecta typo do cliente cedo. Em APIs públicas versionadas, pense antes: rejeitar campos extras pode dificultar compatibilidade com clientes que enviam metadados. O importante é decidir explicitamente, não aceitar qualquer coisa por acidente.

Também vale tratar mensagens de erro com cuidado. Não devolva stack trace nem detalhes internos. Retorne algo útil para o cliente, mas registre o erro completo do lado do servidor com [slog](/blog/slog-go-logging-estruturado/).

## Validação: sintaxe JSON não é regra de negócio

Se `Decode` passou, você sabe que o JSON tem formato válido e cabe nos tipos básicos. Você ainda não sabe se o e-mail é aceitável, se o preço é positivo, se o status pode mudar, se a data está no futuro ou se o usuário tem permissão.

Uma função de validação explícita costuma ser mais legível que espalhar checks pelo handler:

```go
func (r CreateUserRequest) Validate() error {
    var problems []string

    if strings.TrimSpace(r.Name) == "" {
        problems = append(problems, "name é obrigatório")
    }
    if !strings.Contains(r.Email, "@") {
        problems = append(problems, "email inválido")
    }
    if r.Age != nil && *r.Age < 0 {
        problems = append(problems, "age não pode ser negativo")
    }

    if len(problems) > 0 {
        return fmt.Errorf(strings.Join(problems, "; "))
    }
    return nil
}
```

Em bases maiores, bibliotecas de validação podem ajudar, mas não terceirize regra de negócio importante para tags mágicas demais. Go funciona melhor quando invariantes críticas são fáceis de ler, testar e revisar.

## Números, dinheiro e precisão

JSON tem um tipo numérico genérico. Go tem `int`, `int64`, `float64`, `json.Number` e tipos próprios. Essa diferença causa bugs em dinheiro, IDs grandes e integrações externas.

Para dinheiro, prefira centavos em inteiro:

```go
type ChargeRequest struct {
    AmountCents int64  `json:"amount_cents"`
    Currency    string `json:"currency"`
}
```

Evite `float64` para dinheiro. `19.90` não é uma representação decimal exata em ponto flutuante binário. Para leitura genérica, `Decoder.UseNumber()` evita converter todo número para `float64` antes de você decidir o tipo:

```go
dec := json.NewDecoder(r.Body)
dec.UseNumber()

var payload map[string]any
if err := dec.Decode(&payload); err != nil {
    return err
}
```

Ainda assim, `map[string]any` deve ser exceção. Use struct quando o contrato é conhecido.

## Streaming: quando o JSON é grande

Para listas pequenas, codificar um slice inteiro é simples. Para exportações grandes, relatórios ou processamento de arquivos, streaming reduz memória e permite começar a enviar antes de terminar tudo.

Exemplo de resposta em array sem montar tudo na RAM:

```go
func streamUsers(w http.ResponseWriter, users <-chan UserResponse) error {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")

    enc := json.NewEncoder(w)
    w.Write([]byte("["))
    first := true

    for user := range users {
        if !first {
            w.Write([]byte(","))
        }
        first = false
        if err := enc.Encode(user); err != nil {
            return err
        }
    }

    w.Write([]byte("]"))
    return nil
}
```

Em produção, combine isso com contexto, timeout e cancelamento. Se o cliente desconectar, `r.Context()` deve encerrar a query, o worker ou a leitura da fila. Revise [context.Context em Go](/blog/context-timeout-cancelamento-go/) antes de fazer streaming longo.

Para entrada grande no formato NDJSON, em que cada linha é um objeto JSON independente, o padrão é ainda mais simples: ler linha por linha ou chamar `Decode` repetidamente até `io.EOF`.

## Custom MarshalJSON: use com parcimônia

`MarshalJSON` e `UnmarshalJSON` são úteis para datas customizadas, enums, formatos legados e compatibilidade com APIs externas. Mas eles também escondem comportamento. Se toda struct tem método customizado, o contrato fica difícil de auditar.

Um uso saudável é enum com validação:

```go
type Status string

const (
    StatusPending Status = "pending"
    StatusActive  Status = "active"
    StatusBlocked Status = "blocked"
)

func (s Status) Valid() bool {
    switch s {
    case StatusPending, StatusActive, StatusBlocked:
        return true
    default:
        return false
    }
}
```

Você pode validar depois do decode em vez de implementar `UnmarshalJSON`. Isso mantém a mensagem de erro e a regra em um lugar mais previsível.

## E o encoding/json/v2?

O Go tem experimentos em torno de `encoding/json/v2` e `encoding/json/jsontext`, com objetivo de corrigir limitações históricas, melhorar consistência e dar APIs mais explícitas para tokenização e semântica JSON. Para código de produção, a regra é simples: acompanhe a evolução, teste em projetos não críticos e evite apostar o contrato público em API experimental sem necessidade.

O pacote `encoding/json` atual continua sendo a escolha padrão para a maioria dos serviços. O que mais melhora qualidade não é trocar de pacote; é limitar corpo, separar DTO, validar regra de negócio, cobrir contrato com testes e observar erros em produção.

Quando `json/v2` fizer sentido no seu contexto, avalie com benchmark real e teste de compatibilidade. JSON é borda de sistema: pequenas mudanças de comportamento podem afetar clientes, filas, webhooks e cache.

## Testes que pegam bugs reais de JSON

Teste JSON em três níveis:

1. Unit test de validação da struct de request.
2. Teste de handler com `httptest`, corpo inválido e campos desconhecidos.
3. Teste de contrato com payload realista salvo em `testdata`.

Exemplo curto:

```go
func TestDecodeRejectsUnknownField(t *testing.T) {
    body := strings.NewReader(`{"name":"Ana","email":"ana@example.com","admin":true}`)
    req := httptest.NewRequest(http.MethodPost, "/users", body)
    rec := httptest.NewRecorder()

    var input CreateUserRequest
    err := decodeJSON(rec, req, &input)
    if err == nil {
        t.Fatal("esperava erro para campo desconhecido")
    }
}
```

Também teste o caminho feliz serializando a resposta e comparando campos importantes, não necessariamente a string inteira. Ordem de campos em JSON não deve ser parte do contrato, a menos que você esteja testando snapshot conscientemente.

## Checklist de produção

Antes de considerar seu JSON pronto para produção, revise:

- O handler limita tamanho do corpo?
- Campos desconhecidos devem ser aceitos ou rejeitados?
- Existe validação explícita depois do decode?
- Campos opcionais usam ponteiro quando zero é ambíguo?
- Dinheiro e IDs grandes evitam `float64`?
- Erros retornados ao cliente são úteis sem vazar detalhe interno?
- Logs registram causa suficiente para debugging?
- Testes cobrem JSON inválido, campo ausente, campo extra e valor inválido?
- O contrato está documentado em OpenAPI quando há clientes externos?

JSON bom em Go não é sobre decorar todas as opções do `encoding/json`. É sobre tratar a borda do sistema com respeito. Receba pouco, valide cedo, converta para tipos do domínio, responda com contrato claro e teste os casos que quebram em produção.

Se você trabalha em times com múltiplas stacks, compare também como o <a href="https://python.dev.br/blog/apis-rest-com-fastapi/" target="_blank" rel="noopener noreferrer" onclick="umami.track(portfolio-site-click, { destination: python.dev.br })">Python Dev Brasil aborda APIs REST com FastAPI</a>. Frameworks mudam, mas os problemas de contrato, validação, compatibilidade e segurança em JSON são os mesmos.
