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.Marshal retorna []byte e error. Você sempre deve verificar error.
  • A tag json:"id" muda o nome do campo para id no JSON. Se não houver tag, Go usa o nome original do campo, ID.
  • Email vazio é omitido graças a omitempty.
  • Note tem a tag json:"-" então é sempre ignorada.
  • createdBy começa com letra minúscula, ou seja, é unexported. encoding/json nã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:

  1. Chaves são comparadas sem diferenciar maiúsculas de minúsculas. "Customer" ainda corresponde à tag customer.
  2. 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 erro json: 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.
  • null em vez de []. Um slice nil é escrito como null. Se o cliente precisa de um array vazio, inicialize Items: []string{}.
  • Perda de precisão em números grandes. Acontece ao fazer decode para any. Use uma struct com tipo específico (int64), ou UseNumber, ou tag ,string.
  • Erro de tipo de dados silenciosamente engolido. Quando Unmarshal encontra *json.UnmarshalTypeError, ainda preenche outros campos. Não use a struct se err != nil.
  • []byte vira string base64. Este é o comportamento intencional. Se você quer texto plano, use string.
  • time.Time em RFC 3339. Para um formato diferente como dd/MM/yyyy, você precisa escrever um tipo personalizado.
  • Chamar json.Marshal dentro de MarshalJSON com o mesmo tipo. Resultado é stack overflow. Use um alias type como na seção 7.

Nota: encoding/json usa 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 experimental encoding/json/v2, ativada por GOEXPERIMENT=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 Marshal e Unmarshal para dados pequenos.
  • Use struct tags (omitempty, omitzero, string, -) para controlar a saída.
  • Use Decoder com DisallowUnknownFields e UseNumber para ler dados com mais segurança.
  • Use Encoder e Decoder para fazer stream de dados.
  • Use TextMarshaler ou MarshalJSON para 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.

golangjsonencoding/jsonbackend