← Voltar para o blog

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, autenticação e autorização em Go, OpenAPI com oapi-codegen, testes de integração com Testcontainers e webhooks com assinatura e idempotência.

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:

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:

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:

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:

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.

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.

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:

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:

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:

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:

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

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:

func TestDecodeRejectsUnknownField(t *testing.T) {
    body := strings.NewReader(`{"name":"Ana","email":"[email protected]","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 Python Dev Brasil aborda APIs REST com FastAPI. Frameworks mudam, mas os problemas de contrato, validação, compatibilidade e segurança em JSON são os mesmos.