Ler e escrever JSON em Go com encoding/json
1. Visão geral
JSON é o formato de troca de dados mais comum entre serviços. Go suporta JSON nativamente na biblioteca padrão através do pacote encoding/json, você não precisa instalar nada adicional.
Este artigo ajuda você a converter structs para JSON e vice-versa, controlar nomes de campos com struct tags, processar JSON dinâmico e fazer stream de grandes volumes de dados. Você também aprenderá como escrever tipos personalizados e testá-los. No final, conhecerá erros comuns para evitar quando colocar código em produção.
Os exemplos usam Go 1.24 ou superior.
2. Escrevendo JSON com json.Marshal
Começamos com um pedido contendo informações do cliente:
package main
import (
"encoding/json"
"fmt"
"log"
)
type Customer struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
}
type Order struct {
ID int `json:"id"`
Customer Customer `json:"customer"`
Items []string `json:"items"`
Total float64 `json:"total"`
Note string `json:"-"`
createdBy string
}
func main() {
order := Order{
ID: 1001,
Customer: Customer{Name: "Lan"},
Items: []string{"Café", "Pão"},
Total: 45000,
Note: "entregar antes das 9h",
createdBy: "admin",
}
data, err := json.Marshal(order)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}
Resultado:
{"id":1001,"customer":{"name":"Lan"},"items":["Café","Pão"],"total":45000}
Há alguns pontos a observar no código acima:
json.Marshalretorna[]byteeerror. Você sempre deve verificarerror.- A tag
json:"id"muda o nome do campo paraidno JSON. Se não houver tag, Go usa o nome original do campo,ID. Emailvazio é omitido graças aomitempty.Notetem a tagjson:"-"então é sempre ignorada.createdBycomeça com letra minúscula, ou seja, é unexported.encoding/jsonnão vê este campo.- Caracteres UTF-8 são preservados, não escapados.
Se você precisar de JSON legível, por exemplo para logging ou escrita de arquivo de configuração, use json.MarshalIndent:
data, _ := json.MarshalIndent(order.Customer, "", " ")
fmt.Println(string(data))
{
"name": "Lan"
}
3. Struct tag: controle detalhado da saída
A tag json tem a forma json:"nome,opção,...". Opções comuns:
| Tag | Significado |
|---|---|
json:"name" |
Muda o nome do campo |
json:",omitempty" |
Omite o campo se for zero value: "", 0, false, nil, slice ou map vazio |
json:",omitzero" |
Omite o campo se for zero value, incluindo structs como time.Time (Go 1.24+) |
json:",string" |
Escreve números ou bool como string |
json:"-" |
Sempre ignora |
omitempty não funciona com structs. O exemplo abaixo mostra a diferença:
type Shipment struct {
OrderID int64 `json:"order_id,string"`
ShippedAt time.Time `json:"shipped_at,omitempty"`
PaidAt time.Time `json:"paid_at,omitzero"`
}
data, _ := json.Marshal(Shipment{OrderID: 1001})
fmt.Println(string(data))
{"order_id":"1001","shipped_at":"0001-01-01T00:00:00Z"}
ShippedAt ainda aparece porque omitempty não considera um struct time.Time vazio como “empty”. PaidAt usa omitzero então é omitido conforme esperado. OrderID é escrito como string "1001" graças à opção string. Isso é útil quando o cliente é JavaScript e não consegue ler inteiros de 64 bits com precisão.
4. Lendo JSON com json.Unmarshal
No sentido inverso, você passa um ponteiro para a variável de destino:
input := []byte(`{
"id": 1002,
"Customer": {"name": "Minh", "email": "minh@example.com"},
"items": ["Chá de pêssego"],
"total": 20000,
"coupon": "SALE10"
}`)
var order Order
if err := json.Unmarshal(input, &order); err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", order)
{ID:1002 Customer:{Name:Minh Email:minh@example.com} Items:[Chá de pêssego] Total:20000 Note: createdBy:}
Note dois comportamentos padrão:
- Chaves são comparadas sem diferenciar maiúsculas de minúsculas.
"Customer"ainda corresponde à tagcustomer. - Chaves desconhecidas como
"coupon"são silenciosamente ignoradas. Campos não presentes no JSON mantêm seus valores atuais.
Nota: Se você passar um valor em vez de um ponteiro, por exemplo
json.Unmarshal(input, order), você receberá o errojson: Unmarshal(non-pointer main.Order). Os dados não são escritos em lugar algum.
4.1. Rejeitando campos desconhecidos
Em uma API que recebe requests de clientes, ignorar chaves desconhecidas geralmente mascara erros de digitação no nome do campo. Você pode ativar um modo rigoroso usando json.Decoder:
dec := json.NewDecoder(bytes.NewReader(input))
dec.DisallowUnknownFields()
var order Order
err := dec.Decode(&order)
fmt.Println(err)
json: unknown field "coupon"
DisallowUnknownFields só existe em Decoder, não há opção equivalente para json.Unmarshal.
5. JSON dinâmico com map[string]any
Quando a estrutura não é conhecida de antemão, por exemplo webhooks de terceiros, você pode fazer decode para map[string]any:
var payload map[string]any
_ = json.Unmarshal([]byte(`{"event":"order.paid","order_id":9007199254740993}`), &payload)
fmt.Printf("%T %v\n", payload["event"], payload["event"])
fmt.Printf("%T %.0f\n", payload["order_id"], payload["order_id"])
string order.paid
float64 9007199254740992
Todo número é decodificado como float64. Por isso order_id perde precisão: o valor original termina em 3 mas o resultado termina em 2. Este é um erro muito difícil de detectar.
A solução é chamar UseNumber, para que os números sejam mantidos como string json.Number:
dec := json.NewDecoder(strings.NewReader(`{"order_id":9007199254740993}`))
dec.UseNumber()
var payload map[string]any
_ = dec.Decode(&payload)
id, _ := payload["order_id"].(json.Number).Int64()
fmt.Println(id)
9007199254740993
Se apenas parte do payload é dinâmica, você pode usar json.RawMessage para adiar a decodificação dessa parte:
type Event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
Após ler Type, você chama json.Unmarshal(e.Data, &order) com a struct apropriada. Isso é muito mais seguro em termos de tipos do que procurar cada chave no map.
6. Streaming de dados com Encoder e Decoder
Marshal e Unmarshal trabalham com []byte, então todos os dados devem caber na memória. json.Encoder e json.Decoder trabalham diretamente com io.Writer e io.Reader. Eles são adequados para HTTP body, arquivos e dados NDJSON (cada linha é um JSON).
func main() {
input := strings.NewReader(`{"id":1,"total":10000}
{"id":2,"total":25000}
{"id":3,"total":5000}`)
dec := json.NewDecoder(input)
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
sum := 0.0
for {
var o Order
err := dec.Decode(&o)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
log.Fatal(err)
}
sum += o.Total
}
_ = enc.Encode(map[string]float64{"sum": sum})
}
{"sum":40000}
O loop chama Decode até encontrar io.EOF, lendo exatamente um objeto por vez. Encode adiciona automaticamente uma quebra de linha após cada valor. SetEscapeHTML(false) impede que Go converta <, >, & em \u003c, \u003e, \u0026. Por padrão, Go escapa esses caracteres para segurança ao incorporar JSON em HTML.
Em um handler HTTP, você usa esse padrão:
func createOrder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // máximo 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var o Order
if err := dec.Decode(&o); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(o)
}
http.MaxBytesReader limita o tamanho do body. Assim, o cliente não pode enviar um payload gigante que esgote a memória do servidor.
7. Tipos personalizados
Suponha que o status do pedido seja armazenado como int no código mas deva ser exibido como string no JSON. Você implementa encoding.TextMarshaler e encoding.TextUnmarshaler:
type Status int
const (
StatusPending Status = iota
StatusPaid
StatusShipped
)
var statusNames = map[Status]string{
StatusPending: "pending",
StatusPaid: "paid",
StatusShipped: "shipped",
}
func (s Status) MarshalText() ([]byte, error) {
name, ok := statusNames[s]
if !ok {
return nil, fmt.Errorf("status inválido: %d", s)
}
return []byte(name), nil
}
func (s *Status) UnmarshalText(b []byte) error {
for k, v := range statusNames {
if v == string(b) {
*s = k
return nil
}
}
return fmt.Errorf("status inválido: %q", b)
}
Quando usado em uma struct:
type OrderStatus struct {
ID int `json:"id"`
Status Status `json:"status"`
}
data, _ := json.Marshal(OrderStatus{ID: 1001, Status: StatusPaid})
fmt.Println(string(data))
{"id":1001,"status":"paid"}
MarshalText usa value receiver então funciona com ambas Status e *Status. UnmarshalText requer pointer receiver porque precisa modificar o valor. Usar TextMarshaler em vez de json.Marshaler tem a vantagem adicional de que o tipo Status pode ser usado como chave de map, por exemplo map[Status]int.
Quando precisa controlar todo o objeto, você escreve MarshalJSON. Cuidado com recursão infinita: se dentro chamar json.Marshal(o) com o mesmo tipo, a função se chamará infinitamente. A solução é usar um alias type:
func (o Order) MarshalJSON() ([]byte, error) {
type alias Order // alias não carrega o método MarshalJSON
return json.Marshal(struct {
alias
ItemCount int `json:"item_count"`
}{alias(o), len(o.Items)})
}
alias tem os mesmos campos que Order mas sem os métodos, então json.Marshal usa o comportamento padrão. Os campos embutidos são “achatados” no objeto externo, junto com item_count.
8. Escrevendo testes
Testar round-trip, ou seja, escrever e depois ler novamente, ajuda você a detectar erros quando alguém muda uma tag ou a lógica personalizada. Arquivo status_test.go:
package main
import (
"bytes"
"encoding/json"
"testing"
)
func TestStatusRoundTrip(t *testing.T) {
tests := []struct {
status Status
want string
}{
{StatusPending, `{"id":1,"status":"pending"}`},
{StatusPaid, `{"id":1,"status":"paid"}`},
{StatusShipped, `{"id":1,"status":"shipped"}`},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
data, err := json.Marshal(OrderStatus{ID: 1, Status: tt.status})
if err != nil {
t.Fatalf("erro ao fazer marshal: %v", err)
}
if string(data) != tt.want {
t.Errorf("got %s, want %s", data, tt.want)
}
var back OrderStatus
if err := json.Unmarshal(data, &back); err != nil {
t.Fatalf("erro ao fazer unmarshal: %v", err)
}
if back.Status != tt.status {
t.Errorf("got %v, want %v", back.Status, tt.status)
}
})
}
}
func TestRejectUnknownField(t *testing.T) {
dec := json.NewDecoder(bytes.NewBufferString(`{"id":1,"coupon":"X"}`))
dec.DisallowUnknownFields()
var o OrderStatus
if err := dec.Decode(&o); err == nil {
t.Fatal("esperava erro unknown field, recebi nil")
}
}
go test ./...
ok example.com/shop 0.003s
Testes em forma de tabela (table-driven) são a forma comum de escrever em Go. Cada caso é executado em seu próprio t.Run, então quando um caso falha, você vê imediatamente qual é.
9. Erros comuns
- Campo não aparece no JSON. Quase sempre porque o campo tem nome com letra minúscula (unexported). Mude para letra maiúscula e use uma tag para definir o nome no JSON.
nullem vez de[]. Um slicenilé escrito comonull. Se o cliente precisa de um array vazio, inicializeItems: []string{}.- Perda de precisão em números grandes. Acontece ao fazer decode para
any. Use uma struct com tipo específico (int64), ouUseNumber, ou tag,string. - Erro de tipo de dados silenciosamente engolido. Quando
Unmarshalencontra*json.UnmarshalTypeError, ainda preenche outros campos. Não use a struct seerr != nil. []bytevira string base64. Este é o comportamento intencional. Se você quer texto plano, usestring.time.Timeem RFC 3339. Para um formato diferente comodd/MM/yyyy, você precisa escrever um tipo personalizado.- Chamar
json.Marshaldentro deMarshalJSONcom o mesmo tipo. Resultado é stack overflow. Use um alias type como na seção 7.
Nota:
encoding/jsonusa reflection então não é a escolha mais rápida. Para a maioria das APIs, sua velocidade é completamente adequada. Só quando profiling mostra que JSON é um gargalo você deve considerar bibliotecas que geram código. Go 1.25 também tem uma versão experimentalencoding/json/v2, ativada porGOEXPERIMENT=jsonv2. Esta versão ainda não é estável então não é adequada para produção.
10. Conclusão
Você passou pelos principais pontos sobre encoding/json:
- Use
MarshaleUnmarshalpara dados pequenos. - Use struct tags (
omitempty,omitzero,string,-) para controlar a saída. - Use
DecodercomDisallowUnknownFieldseUseNumberpara ler dados com mais segurança. - Use
EncodereDecoderpara fazer stream de dados. - Use
TextMarshalerouMarshalJSONpara tipos personalizados.
O princípio geral é preferir structs com tipos claros em vez de map[string]any, e sempre verificar error. Você também deve escrever testes round-trip para tipos personalizados. Esses três hábitos ajudam você a evitar a maioria dos erros JSON em serviços escritos em Go.