Trabalhando com JSON: encode, decode e tags de struct

Trabalhando com JSON: encode, decode e tags de struct

JSON em Go: Marshal e Unmarshal, tags com omitempty, omitzero, string e -, Encoder e Decoder para streaming, map[string]any e a perda de precisão acima de 2^53, RawMessage, MarshalJSON próprios, validação estrita e os riscos de decodificar requisições direto no modelo.
Golang

• • 15 min de leitura

JSON tornou-se o formato padrão de troca de dados entre sistemas. APIs REST, arquivos de configuração, mensagens em filas, respostas de webhooks — praticamente todo sistema moderno produz ou consome JSON em algum ponto. Go oferece suporte nativo e completo através do pacote encoding/json, com uma API que equilibra simplicidade, flexibilidade e performance.

O pacote usa reflection para inspecionar structs em tempo de execução e mapear campos automaticamente. Isso significa que a maior parte do trabalho de serialização e desserialização acontece sem código manual — basta definir a struct corretamente.

Conceitos fundamentais: Marshal e Unmarshal

Marshal converte um valor Go em JSON. Unmarshal faz o caminho inverso — converte JSON em um valor Go:

package main

import (
    "encoding/json"
    "fmt"
)

type Produto struct {
    ID       int     `json:"id"`
    Nome     string  `json:"nome"`
    Preco    float64 `json:"preco"`
    Estoque  int     `json:"estoque"`
    Ativo    bool    `json:"ativo"`
}

func main() {
    // Go → JSON (Marshal)
    p := Produto{
        ID:      1,
        Nome:    "Teclado Mecânico",
        Preco:   349.90,
        Estoque: 42,
        Ativo:   true,
    }

    dados, err := json.Marshal(p)
    if err != nil {
        fmt.Println("Erro ao serializar:", err)
        return
    }
    fmt.Println(string(dados))
    // {"id":1,"nome":"Teclado Mecânico","preco":349.9,"estoque":42,"ativo":true}

    // JSON → Go (Unmarshal)
    jsonStr := `{"id":2,"nome":"Mouse Gamer","preco":189.90,"estoque":15,"ativo":true}`
    var p2 Produto
    if err := json.Unmarshal([]byte(jsonStr), &p2); err != nil {
        fmt.Println("Erro ao deserializar:", err)
        return
    }
    fmt.Printf("%+v\n", p2)
    // {ID:2 Nome:Mouse Gamer Preco:189.9 Estoque:15 Ativo:true}
}

Tags de struct: controlando a serialização

As tags json nos campos da struct controlam como cada campo é serializado e deserializado. A sintaxe completa é json:"nome,opções":

type Usuario struct {
    ID        int       `json:"id"`
    Nome      string    `json:"nome"`
    Email     string    `json:"email"`
    Senha     string    `json:"-"`                  // sempre omitido
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at,omitzero"`  // omitido se zero (Go 1.24+)
    Apelido   string    `json:"apelido,omitempty"`    // omitido se vazio
    Interno   string    `json:",omitempty"`           // usa nome do campo, omite se vazio
    Debug     string    `json:"-,"`                  // nome literal é "-"
}

As opções mais importantes:

  • omitempty — omite o campo se for “vazio”: 0, "", false, nil, slice vazio, map vazio. Structs nunca contam como vazias — um time.Time zerado com omitempty sai como "0001-01-01T00:00:00Z"
  • omitzero (Go 1.24+) — omite qualquer valor zero, structs inclusive, e usa o método IsZero() quando o tipo tem um
  • - — sempre omite o campo da serialização
  • string — serializa números e bools como strings JSON
type Pagamento struct {
    Valor    float64 `json:"valor,string"` // serializa como "349.90" em vez de 349.90
    Parcelas int     `json:"parcelas,string"`
}

p := Pagamento{Valor: 349.90, Parcelas: 3}
dados, _ := json.Marshal(p)
fmt.Println(string(dados)) // {"valor":"349.9","parcelas":"3"}

MarshalIndent: JSON formatado para leitura humana

dados, err := json.MarshalIndent(produto, "", "  ")
if err != nil {
    fmt.Println("Erro:", err)
    return
}
fmt.Println(string(dados))
// {
//   "id": 1,
//   "nome": "Teclado Mecânico",
//   "preco": 349.9,
//   "estoque": 42,
//   "ativo": true
// }

O segundo argumento é o prefixo de cada linha — normalmente vazio. O terceiro é o recuo por nível — convencionalmente dois ou quatro espaços.

Tipos complexos: slices, maps e structs aninhadas

JSON aninhado mapeia naturalmente para structs aninhadas e slices:

package main

import (
    "encoding/json"
    "fmt"
)

type Endereco struct {
    Rua    string `json:"rua"`
    Cidade string `json:"cidade"`
    Estado string `json:"estado"`
    CEP    string `json:"cep"`
}

type Contato struct {
    Tipo  string `json:"tipo"`  // "email", "telefone"
    Valor string `json:"valor"`
}

type Cliente struct {
    ID       int       `json:"id"`
    Nome     string    `json:"nome"`
    Endereco Endereco  `json:"endereco"`
    Contatos []Contato `json:"contatos"`
    Tags     []string  `json:"tags,omitempty"`
    Metadata map[string]string `json:"metadata,omitempty"`
}

func main() {
    cliente := Cliente{
        ID:   1,
        Nome: "Empresa Tech Ltda",
        Endereco: Endereco{
            Rua:    "Av. Paulista, 1000",
            Cidade: "São Paulo",
            Estado: "SP",
            CEP:    "01310-100",
        },
        Contatos: []Contato{
            {Tipo: "email", Valor: "contato@tech.com"},
            {Tipo: "telefone", Valor: "(11) 3000-0000"},
        },
        Tags: []string{"premium", "ativo"},
        Metadata: map[string]string{
            "segmento": "tecnologia",
            "regiao":   "sudeste",
        },
    }

    dados, _ := json.MarshalIndent(cliente, "", "  ")
    fmt.Println(string(dados))

    // Deserializando de volta
    var c2 Cliente
    json.Unmarshal(dados, &c2)
    fmt.Println(c2.Contatos[0].Valor) // contato@tech.com
}

Encoder e Decoder: streaming de JSON

Para I/O com arquivos, conexões de rede e requisições HTTP, json.Encoder e json.Decoder trabalham diretamente com io.Writer e io.Reader, evitando a alocação de buffers intermediários:

package main

import (
    "encoding/json"
    "fmt"
    "os"
    "strings"
)

type Evento struct {
    Tipo    string `json:"tipo"`
    Payload string `json:"payload"`
    TS      int64  `json:"ts"`
}

func main() {
    // Encoder — escreve diretamente em qualquer Writer
    encoder := json.NewEncoder(os.Stdout)
    encoder.SetIndent("", "  ") // equivalente ao MarshalIndent

    eventos := []Evento{
        {Tipo: "login", Payload: "user_42", TS: 1710000000},
        {Tipo: "compra", Payload: "pedido_1001", TS: 1710000060},
        {Tipo: "logout", Payload: "user_42", TS: 1710000120},
    }

    for _, e := range eventos {
        encoder.Encode(e) // cada Encode escreve um objeto JSON + newline
    }

    // Decoder — lê de qualquer Reader
    // Útil para processar múltiplos objetos JSON em sequência (NDJSON)
    ndjson := `{"tipo":"ping","payload":"","ts":1710000180}
{"tipo":"pong","payload":"","ts":1710000181}
`
    decoder := json.NewDecoder(strings.NewReader(ndjson))
    for decoder.More() { // More() verifica se há mais valores
        var e Evento
        if err := decoder.Decode(&e); err != nil {
            fmt.Println("Erro:", err)
            return
        }
        fmt.Printf("tipo=%s ts=%d\n", e.Tipo, e.TS)
    }
}

JSON dinâmico com map[string]any

Quando a estrutura do JSON não é conhecida em tempo de compilação, map[string]any recebe qualquer objeto JSON:

package main

import (
    "encoding/json"
    "fmt"
)

func main() {
    jsonStr := `{
        "nome": "Ricardo",
        "idade": 35,
        "ativo": true,
        "scores": [9.5, 8.8, 9.1],
        "endereco": {
            "cidade": "São Paulo"
        }
    }`

    var dados map[string]any
    if err := json.Unmarshal([]byte(jsonStr), &dados); err != nil {
        fmt.Println("Erro:", err)
        return
    }

    // Acessando valores com type assertion
    nome := dados["nome"].(string)
    idade := dados["idade"].(float64) // JSON numbers → float64
    ativo := dados["ativo"].(bool)

    fmt.Printf("Nome: %s, Idade: %.0f, Ativo: %v\n", nome, idade, ativo)

    // Slice de any
    scores := dados["scores"].([]any)
    for _, s := range scores {
        fmt.Printf("%.1f ", s.(float64))
    }
    fmt.Println()

    // Map aninhado
    endereco := dados["endereco"].(map[string]any)
    fmt.Println("Cidade:", endereco["cidade"])
}

Atenção: números em JSON sempre viram float64 quando deserializados em any. Para inteiros, use json.Decoder com UseNumber():

decoder := json.NewDecoder(strings.NewReader(jsonStr))
decoder.UseNumber() // preserva números como json.Number

var dados map[string]any
decoder.Decode(&dados)

idade, _ := dados["idade"].(json.Number).Int64()
fmt.Println(idade) // 35 como int64

json.RawMessage: adiando a deserialização

json.RawMessage armazena JSON bruto sem interpretá-lo, permitindo deserializar partes diferentes de um mesmo objeto em momentos diferentes:

package main

import (
    "encoding/json"
    "fmt"
)

type Mensagem struct {
    Tipo    string          `json:"tipo"`
    Payload json.RawMessage `json:"payload"` // serializado mas não interpretado
}

type PayloadLogin struct {
    UserID int    `json:"user_id"`
    IP     string `json:"ip"`
}

type PayloadCompra struct {
    PedidoID int     `json:"pedido_id"`
    Valor    float64 `json:"valor"`
}

func processarMensagem(dados []byte) error {
    var msg Mensagem
    if err := json.Unmarshal(dados, &msg); err != nil {
        return err
    }

    switch msg.Tipo {
    case "login":
        var p PayloadLogin
        json.Unmarshal(msg.Payload, &p)
        fmt.Printf("Login: user=%d ip=%s\n", p.UserID, p.IP)

    case "compra":
        var p PayloadCompra
        json.Unmarshal(msg.Payload, &p)
        fmt.Printf("Compra: pedido=%d valor=R$%.2f\n", p.PedidoID, p.Valor)

    default:
        fmt.Printf("Tipo desconhecido: %s\n", msg.Tipo)
    }

    return nil
}

func main() {
    mensagens := []string{
        `{"tipo":"login","payload":{"user_id":42,"ip":"192.168.1.1"}}`,
        `{"tipo":"compra","payload":{"pedido_id":1001,"valor":349.90}}`,
    }

    for _, m := range mensagens {
        processarMensagem([]byte(m))
    }
}

Customizando serialização com MarshalJSON e UnmarshalJSON

Para controle total sobre como um tipo é serializado, implemente as interfaces json.Marshaler e json.Unmarshaler:

package main

import (
    "encoding/json"
    "fmt"
    "strings"
    "time"
)

// Tipo de data com formato personalizado
type Data struct {
    time.Time
}

func (d Data) MarshalJSON() ([]byte, error) {
    return json.Marshal(d.Format("02/01/2006"))
}

func (d *Data) UnmarshalJSON(dados []byte) error {
    var s string
    if err := json.Unmarshal(dados, &s); err != nil {
        return err
    }
    t, err := time.Parse("02/01/2006", s)
    if err != nil {
        return err
    }
    d.Time = t
    return nil
}

// Tipo de status com representação textual
type Status int

const (
    StatusPendente Status = iota
    StatusAprovado
    StatusRejeitado
)

func (s Status) MarshalJSON() ([]byte, error) {
    nomes := map[Status]string{
        StatusPendente:  "pendente",
        StatusAprovado:  "aprovado",
        StatusRejeitado: "rejeitado",
    }
    nome, ok := nomes[s]
    if !ok {
        return nil, fmt.Errorf("status desconhecido: %d", s)
    }
    return json.Marshal(nome)
}

func (s *Status) UnmarshalJSON(dados []byte) error {
    var nome string
    if err := json.Unmarshal(dados, &nome); err != nil {
        return err
    }
    codigos := map[string]Status{
        "pendente":  StatusPendente,
        "aprovado":  StatusAprovado,
        "rejeitado": StatusRejeitado,
    }
    codigo, ok := codigos[strings.ToLower(nome)]
    if !ok {
        return fmt.Errorf("status inválido: %s", nome)
    }
    *s = codigo
    return nil
}

type Pedido struct {
    ID         int    `json:"id"`
    DataPedido Data   `json:"data_pedido"`
    Status     Status `json:"status"`
    Total      float64 `json:"total"`
}

func main() {
    pedido := Pedido{
        ID:         1001,
        DataPedido: Data{time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC)},
        Status:     StatusAprovado,
        Total:      549.90,
    }

    dados, _ := json.MarshalIndent(pedido, "", "  ")
    fmt.Println(string(dados))
    // {
    //   "id": 1001,
    //   "data_pedido": "15/03/2024",
    //   "status": "aprovado",
    //   "total": 549.9
    // }

    jsonStr := `{"id":1002,"data_pedido":"20/03/2024","status":"pendente","total":299.90}`
    var p2 Pedido
    json.Unmarshal([]byte(jsonStr), &p2)
    fmt.Printf("Pedido %d em %s — %v\n", p2.ID, p2.DataPedido.Format("02/01/2006"), p2.Status)
}

Validação de JSON

package main

import (
    "encoding/json"
    "fmt"
    "strings"
)

type Produto struct {
    ID   int    `json:"id"`
    Nome string `json:"nome"`
}

func validarJSON(dados []byte) bool {
    return json.Valid(dados)
}

func main() {
    valido := []byte(`{"nome":"Ricardo","idade":35}`)
    invalido := []byte(`{"nome":"Ricardo","idade":}`)

    fmt.Println(validarJSON(valido))   // true
    fmt.Println(validarJSON(invalido)) // false

    // Decodificação estrita — rejeita campos desconhecidos
    jsonStr := `{"id":1,"nome":"Ana","campo_desconhecido":"valor"}`
    decoder := json.NewDecoder(strings.NewReader(jsonStr))
    decoder.DisallowUnknownFields()

    var p Produto
    if err := decoder.Decode(&p); err != nil {
        fmt.Println("Erro:", err)
        // json: unknown field "campo_desconhecido"
    }
}

O encoding/json faz quase tudo sozinho a partir das tags da struct, e é justamente essa conveniência que pede atenção nas bordas do sistema. Na saída, decida com omitempty, omitzero e - o que o mundo pode ver. Na entrada, lembre que o decodificador preenche qualquer campo exportado, ignora maiúsculas nos nomes, aceita chaves repetidas e transforma números grandes em float64 quando o destino é any. Uma struct de entrada separada do modelo interno, com limite de tamanho e erros conferidos, resolve a maior parte dos problemas antes que eles cheguem ao banco.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Uma API de cadastro decodifica o corpo da requisição direto na struct Usuario, que tem os campos Nome string `json:"nome"` e Admin bool `json:"admin"`. O front-end só envia o nome. Um usuário mal-intencionado envia {"nome":"bia","Admin":true} e ganha privilégios de administrador. Explique os dois comportamentos do encoding/json que permitiram isso e como fechar a brecha.

Ver resposta

✓ Resposta: O primeiro comportamento é que o Unmarshal preenche qualquer campo exportado que encontre no JSON, sem saber quais o front-end costuma enviar; se o campo existe na struct, ele é aceito. O segundo é que a correspondência entre chave e campo não diferencia maiúsculas de minúsculas: "Admin" casa com a tag admin. Num teste, {"NOME":"ana","Admin":true,"nome":"bia"} resultou em {Nome:bia Admin:true}, com o Admin preenchido pela chave em maiúscula e o nome decidido pela última ocorrência, porque chaves repetidas não são erro e a última vence. A brecha fecha com uma struct de entrada que tenha apenas os campos que o cliente pode definir, como type CadastroEntrada struct { Nome string `json:"nome"` }, convertida depois para a struct interna. DisallowUnknownFields() ajuda a rejeitar campos extras, mas não resolve sozinho, porque admin é um campo conhecido. A regra geral é nunca decodificar uma requisição direto no modelo que vai para o banco.

Exercício 2

Um serviço recebe IDs de pedido gerados por outro sistema, números de 64 bits como 9007199254740993, e os lê num map[string]any. Alguns pedidos passam a ser associados ao ID errado. De onde vem o erro, e quais são as duas formas de lê-los corretamente?

Ver resposta

✓ Resposta: Ao decodificar num any, todo número JSON vira float64, como o artigo avisa, e o float64 só representa com exatidão inteiros até 253, que é 9007199254740992. Acima disso, alguns valores não existem no formato e são arredondados para o vizinho representável. Num teste, {"id": 9007199254740993} lido num map[string]any virou 9007199254740992, um pedido que é de outro cliente; lido numa struct com ID int64, o mesmo JSON deu o valor exato. A primeira forma de resolver é decodificar numa struct com o tipo inteiro certo, o que o encoding/json faz sem passar por float64. A segunda, para quando a estrutura é mesmo dinâmica, é decoder.UseNumber(), também mostrado no artigo, que guarda o número como json.Number, um texto, e deixa a conversão para Int64() quando você souber o tipo. Esse limite de 253 também vale para o JavaScript, e por isso muitas APIs enviam IDs grandes como string; a opção ,string da tag lê e escreve nesse formato.

Exercício 3

Uma struct tem AtualizadoEm time.Time `json:"atualizado_em,omitempty"`, na expectativa de que registros nunca atualizados não mostrem o campo. As respostas trazem "atualizado_em":"0001-01-01T00:00:00Z". Por que o omitempty não funcionou, e o que muda com omitzero?

Ver resposta

✓ Resposta: Para o encoding/json, "vazio" é uma lista fechada: zero numérico, false, string, fatia ou map de tamanho zero, ponteiro ou interface nil. Struct nunca entra na lista, e time.Time é uma struct; o valor zero, 1º de janeiro do ano 1, é serializado mesmo com omitempty. Uma versão anterior deste artigo trazia justamente esse exemplo com o comentário "omitido se zero", que não correspondia ao comportamento real. O omitzero, disponível desde o Go 1.24, omite qualquer valor igual ao zero do tipo, structs inclusive, e, se o tipo tiver um método IsZero() bool, usa esse método para decidir, o que o time.Time tem. As duas opções podem até aparecer juntas, e o campo é omitido se qualquer uma se aplicar. Em versões anteriores ao 1.24, a saída era declarar o campo como *time.Time, com nil para "nunca atualizado". Essa forma continua útil quando é preciso distinguir o valor ausente de um valor zero legítimo.

Exercício 4

No exemplo de json.RawMessage, a função processarMensagem chama json.Unmarshal(msg.Payload, &p) sem conferir o erro. Uma mensagem chega com {"tipo":"compra","payload":{"pedido_id":"mil"}}. O que o programa imprime, e como reescrever o switch para que payloads inválidos não passem despercebidos?

Ver resposta

✓ Resposta: O Unmarshal falha, porque "mil" é uma string e o campo PedidoID é int, e devolve um erro de tipo que o código descarta. A variável p fica com os valores zero dos campos que não puderam ser preenchidos, e o programa imprime algo como Compra: pedido=0 valor=R$0.00, como se fosse uma compra válida. Num sistema real, esse registro seguiria para o banco ou para a cobrança. A reescrita trata o erro de cada ramo e o devolve com contexto: if err := json.Unmarshal(msg.Payload, &p); err != nil { return fmt.Errorf("payload de compra: %w", err) }. Também convém que o default devolva um erro em vez de apenas imprimir, para que um tipo de mensagem desconhecido não seja tratado como sucesso por quem chamou. A regra é a mesma do CSV no artigo anterior, Leitura e escrita de arquivos com os pacotes os e bufio: um dado inválido deve parar o processamento ou ser registrado como rejeitado, nunca virar silenciosamente um dado zerado.

Exercício 5

Um handler lê o corpo de uma requisição com json.NewDecoder(r.Body).Decode(&entrada). Um cliente envia {"nome":"ana"}{"nome":"bia"}, e outro envia um corpo de 2 GB. O que acontece em cada caso, e quais proteções o handler deveria ter?

Ver resposta

✓ Resposta: No primeiro caso, o Decoder lê o primeiro objeto, preenche entrada com "ana" e retorna sem erro; o segundo objeto fica no corpo, sem ser lido. O Decoder foi feito para fluxos de vários valores, como o NDJSON do artigo, e não reclama do que vem depois. Para exigir exatamente um objeto, é preciso conferir que não sobrou nada: depois do Decode, uma segunda chamada deve devolver io.EOF, ou então decoder.More() deve ser falso. No segundo caso, o Decoder tenta ler e alocar tudo o que chegar, e um corpo enorme pode esgotar a memória do servidor, o que é um vetor clássico de negação de serviço. A proteção é limitar o tamanho antes de decodificar, com r.Body = http.MaxBytesReader(w, r.Body, 1<<20), que devolve erro ao passar de 1 MB. Somando DisallowUnknownFields(), mostrado no artigo, o handler passa a aceitar só o que foi desenhado para receber: um objeto, com os campos esperados e de tamanho razoável.

Comentários

Mais em Golang

Goroutines: concorrência leve e o modelo de execução do Go
Goroutines: concorrência leve e o modelo de execução do Go

Goroutines em Go: o custo comparado ao de threads, o scheduler M:N e a…

A biblioteca padrão: um tour pelas principais ferramentas
A biblioteca padrão: um tour pelas principais ferramentas

Tour pela biblioteca padrão do Go: fmt, strings, strconv, time, os, io e…

Introdução ao Go: história, filosofia e os criadores da linguagem
Introdução ao Go: história, filosofia e os criadores da linguagem

A origem do Go: o build de 45 minutos que motivou Griesemer, Pike e Thompson…