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_caseem 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
omitemptyquando ausência for melhor quenullpara 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:
- Unit test de validação da struct de request.
- Teste de handler com
httptest, corpo inválido e campos desconhecidos. - 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.