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 — umtime.Timezerado comomitemptysai como"0001-01-01T00:00:00Z"omitzero(Go 1.24+) — omite qualquer valor zero, structs inclusive, e usa o métodoIsZero()quando o tipo tem um-— sempre omite o campo da serializaçãostring— 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
-
Documentação do pacote encoding/json — Referência completa de todas as funções e tipos. https://pkg.go.dev/encoding/json
-
Go Blog: JSON and Go — Artigo introdutório oficial sobre JSON em Go. https://go.dev/blog/json
-
Go by Example: JSON — Exemplos práticos comentados. https://gobyexample.com/json
-
json-iterator/go — Implementação alternativa compatível com encoding/json, mais performática. https://github.com/json-iterator/go
-
go-playground/validator — Biblioteca popular para validação de structs com tags. https://github.com/go-playground/validator
-
tidwall/gjson — Biblioteca para consultas rápidas em JSON sem deserialização completa. https://github.com/tidwall/gjson
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.