Go 1.27 Reescreveu o JSON do Zero: 10x Mais Rápido e 6 Anos de Espera
Seis anos. Foi esse o tempo que a equipe do Go levou para admitir que o encoding/json estava fundamentalmente quebrado e reescrever tudo do zero. O resultado, lançado oficialmente no Go 1.27, é o novo encoding/json/v2, um pacote que corrige falhas de segurança que existiam desde 2012, entrega unmarshal até 10x mais rápido e, de quebra, permite que você migre no seu ritmo sem quebrar uma linha sequer do código existente.
Se você programa em Go e já sofreu com aquele nil virando null no JSON, ou gastou horas debugando um campo que não batia porque o match era case-insensitive, esse artigo é pra você.
O Que Estava Errado com o encoding/json Original
Eu já vi gente defendendo o encoding/json do Go como “simples e funcional”. E ele era, até você precisar de qualquer coisa minimamente séria. O pacote original, presente desde o Go 1.0 em 2012, acumulou uma lista impressionante de problemas que a equipe core classificou em três categorias: falhas de comportamento, deficiências de API e limitações de performance.
Falhas de Comportamento (as piores)
A mais grave: o encoding/json aceitava UTF-8 inválido sem reclamar. Você podia enfiar bytes corrompidos num campo string e o marshal seguia feliz. A RFC 8259 exige UTF-8 válido, mas o Go simplesmente ignorava isso.
Outra bomba: nomes duplicados em objetos JSON. Se um JSON chegava com duas chaves iguais, o v1 aceitava silenciosamente e usava o último valor. Parece inofensivo? É uma brecha de segurança conhecida. Sistemas de validação leem a primeira ocorrência, mas a aplicação usa a segunda. Atacantes exploram exatamente essa inconsistência.
E tem mais:
| Comportamento | v1 (quebrado) | v2 (correto) | |
|---|---|---|---|
| — | — | — | |
Slice/map nil |
Serializa como null |
Serializa como [] ou {} |
|
| Match de campos | Case-insensitive | Case-sensitive | |
omitempty em structs |
Não verifica o valor | Omite null, "", [], {} |
|
time.Duration |
Representação padrão inconsistente | Exige opção explícita |
Aquele comportamento do nil slice virando null já causou bugs em produção em centenas de APIs. Eu mesmo já perdi uma tarde inteira porque um frontend em React quebrava ao receber null onde esperava []. O v2 finalmente faz o óbvio: nil slice vira [], nil map vira {}.
Deficiências de API
O v1 tinha limitações estruturais que nenhum workaround resolvia direito:
json.Decoderaceitava lixo depois do JSON válido sem avisar- Options como
SetEscapeHTMLsó funcionavam noEncoder/Decoder, não noMarshal/Unmarshal - Funções como
CompacteIndentsó escreviam embytes.Buffer, não emio.Writergenérico - Não existia forma limpa de customizar serialização sem implementar
MarshalJSONna própria struct
Gargalos de Performance
Aqui é onde dói no bolso (ou no latency do seu serviço):
// v1: MarshalJSON OBRIGA alocação de []byte
func (t MyType) MarshalJSON() ([]byte, error) {
// Tem que criar o slice, não tem como streamar
return json.Marshal(t.internal)
}
Toda chamada ao MarshalJSON no v1 forçava uma alocação. Toda. E o UnmarshalJSON era pior: precisava do JSON completo em memória antes de começar a parsear. Se você tinha structs aninhadas, cada nível chamava Marshal/Unmarshal recursivamente, e o resultado era performance quadrática. O Go blog oficial cita esse problema textualmente.
A Arquitetura do v2: Duas Camadas, Zero Bagunça
A decisão de design mais inteligente do v2 foi separar o pacote em duas camadas:
encoding/json/jsontext: Lida com a gramática JSON pura. Tokens, valores, encoding, decoding. Não sabe nada sobre structs Go.
encoding/json/v2: Faz o mapeamento entre JSON e tipos Go. Usa o jsontext por baixo.
package jsontext
type Encoder struct { ... }
func NewEncoder(io.Writer, ...Options) *Encoder
func (*Encoder) WriteValue(Value) error
func (*Encoder) WriteToken(Token) error
type Decoder struct { ... }
func NewDecoder(io.Reader, ...Options) *Decoder
func (*Decoder) ReadValue() (Value, error)
func (*Decoder) ReadToken() (Token, error)
type Value []byte // Unidade JSON completa (tipo RawMessage)
type Token struct{} // Token individual, sem alocação
Essa separação resolve um monte de problemas de uma vez. Quer processar JSON sem mapear pra structs? Usa o jsontext direto. Quer streaming de verdade? O Token é allocation-free. Quer customizar parsing pra um tipo específico sem tocar na struct? Agora dá.
A Nova API: Três Níveis de Abstração
O v2 oferece três formas de serializar e deserializar, cada uma pra um caso de uso diferente:
package json
// Nível 1: Operação em memória (mais simples)
func Marshal(in any, opts ...Options) ([]byte, error)
func Unmarshal(in []byte, out any, opts ...Options) error
// Nível 2: Streaming com io.Reader/Writer
func MarshalWrite(out io.Writer, in any, opts ...Options) error
func UnmarshalRead(in io.Reader, out any, opts ...Options) error
// Nível 3: Controle total com Encoder/Decoder
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error
func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error
Repare que todas as funções aceitam Options. Essa foi uma das maiores reclamações do v1: você não conseguia passar opções pro Marshal/Unmarshal. Tinha que criar um Encoder, configurar, e aí usar. Agora tudo é uniforme.
Interfaces Novas: Streaming de Verdade
O v1 tinha MarshalJSON e UnmarshalJSON. Funcionavam, mas forçavam alocação. O v2 introduz duas interfaces novas que permitem streaming real:
// Nova: Marshal direto pro Encoder, sem alocar []byte
type MarshalerTo interface {
MarshalJSONTo(*jsontext.Encoder) error
}
// Nova: Unmarshal direto do Decoder, sem buffer intermediário
type UnmarshalerFrom interface {
UnmarshalJSONFrom(*jsontext.Decoder) error
}
Na prática, isso significa que tipos com parsing complexo (árvores, grafos, qualquer coisa recursiva) podem processar JSON em streaming, token por token, sem jamais bufferizar o valor inteiro na memória.
O Go blog cita o caso da spec OpenAPI do Kubernetes: ao trocar UnmarshalJSON por UnmarshalJSONFrom, a melhoria de performance foi “orders of magnitude”. Não 2x. Não 5x. Ordens de magnitude.
Customização Sem Tocar na Struct
Essa é provavelmente a feature mais pedida da história do encoding/json. No v1, se você queria customizar como um tipo era serializado, tinha que implementar MarshalJSON na struct. Se a struct era de um pacote externo, azar o seu.
No v2, você passa a customização no ponto de chamada:
// Customizar marshal de time.Time sem tocar no tipo
json.Marshal(data,
json.WithMarshalers(
json.MarshalFunc[time.Time](func(t time.Time) ([]byte, error) {
return []byte(`"` + t.Format("02/01/2006") + `"`), nil
}),
),
)
// Customizar unmarshal de qualquer tipo externo
json.Unmarshal(input, &result,
json.WithUnmarshalers(
json.UnmarshalFunc[ThirdPartyType](customParser),
),
)
Isso elimina dezenas de wrappers e tipos auxiliares que todo projeto Go acumula só pra customizar JSON.
Benchmarks: O Unmarshal Ficou Absurdamente Rápido
Vamos aos números, porque é aqui que o v2 realmente brilha.
| Operação | v1 | v2 | Diferença | |
|---|---|---|---|---|
| — | — | — | — | |
| Marshal (twitter.json) | 198 MB/s | 374 MB/s | ~1.9x mais rápido | |
| Unmarshal (structs complexas) | baseline | até 10x | depende da complexidade | |
| Marshal (tipos concretos) | baseline | 1.4x mais rápido a 1.2x mais lento | varia | |
Unmarshal com UnmarshalerFrom |
baseline | “ordens de magnitude” | streaming elimina alocações |
O marshal ficou grosso modo no mesmo patamar, com ganhos em alguns cenários e perdas pequenas em outros. O unmarshal é onde a diferença aparece de verdade: o blog de Daniel Lemire mediu melhorias de 2x a 3x em structs complexas aninhadas, e cenários com as novas interfaces de streaming chegaram a 10x.
Se o seu serviço gasta tempo significativo parseando JSON (e qual API não gasta?), o upgrade pra v2 é basicamente performance grátis.
Migração: Faça No Seu Ritmo
A parte mais elegante do design do v2 é a estratégia de migração. Ninguém precisa reescrever nada:
- O
encoding/jsonv1 continua funcionando para sempre. A equipe do Go garantiu isso. - Internamente, o v1 do Go 1.27 já roda em cima do v2 com flags de compatibilidade.
- Pacotes usando v1 e v2 coexistem perfeitamente no mesmo binário.
// Migração gradual: use v2 mas mantenha comportamento v1 onde precisar
import jsonv2 "encoding/json/v2"
// Modo v2 completo
jsonv2.Marshal(data)
// Modo v2 mas aceitando nomes duplicados (comportamento v1)
jsonv2.Marshal(data,
jsontext.AllowDuplicateNames(true),
)
// Modo v1 mas rejeitando nomes duplicados (upgrade parcial)
json.Marshal(data, // v1!
jsontext.AllowDuplicateNames(false),
)
Cada opção pode ser ligada ou desligada individualmente. Você pode migrar um endpoint por vez, uma struct por vez. E como o v1 roda em cima do v2 internamente, seus marshalers v1 funcionam com unmarshaling v2 e vice-versa.
O Que Vai Quebrar Se Você Migrar Pro v2
Nem tudo são flores. Se você trocar pro encoding/json/v2 direto, alguns comportamentos mudam e podem quebrar seu código:
1. UTF-8 inválido agora gera erro
Se algum sistema manda bytes corrompidos nos seus JSONs, o v2 vai rejeitar. Solução: limpe os dados na entrada ou use a opção de compatibilidade.
2. Nomes duplicados agora geram erro
JSONs mal-formados com chaves repetidas vão falhar. Isso é bom pra segurança, mas pode quebrar integrações com APIs bagunçadas de terceiros.
3. Match de campos é case-sensitive
Se sua API retorna UserName e sua struct tem Username, o v1 fazia match. O v2 não. Use tags json:"UserName" explícitas.
4. Nil slices agora viram [] em vez de null
Se algum consumer depende de receber null pra representar “lista vazia”, vai precisar de ajuste. Mas convenhamos: isso era um bug, não uma feature.
// Se precisar do comportamento antigo pra algum campo específico:
type Response struct {
Items []string `json:"items,omitempty"` // omitempty agora funciona direito
}
Antes e Depois: Um Exemplo Real
Pra deixar claro o tamanho da diferença, olha um cenário comum: você tem uma API que recebe um JSON com campos opcionais e precisa customizar a serialização de datas.
Com v1 (o jeito velho):
// Wrapper só pra customizar o formato de data
type CustomTime struct {
time.Time
}
func (ct CustomTime) MarshalJSON() ([]byte, error) {
return []byte(`"` + ct.Format("02/01/2006") + `"`), nil
}
func (ct *CustomTime) UnmarshalJSON(b []byte) error {
t, err := time.Parse(`"02/01/2006"`, string(b))
if err != nil {
return err
}
ct.Time = t
return nil
}
type User struct {
Name string `json:"name"`
CreatedAt CustomTime `json:"created_at"` // tipo wrapper
Tags []string `json:"tags"`
}
Com v2 (o jeito novo):
type User struct {
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"` // tipo original!
Tags []string `json:"tags"`
}
// Customização no ponto de chamada, sem wrappers
out, _ := jsonv2.Marshal(user,
json.WithMarshalers(
json.MarshalFunc[time.Time](func(t time.Time) ([]byte, error) {
return []byte(`"` + t.Format("02/01/2006") + `"`), nil
}),
),
)
Menos código, menos tipos auxiliares, menos indireção. E a struct User continua limpa, sem wrappers que poluem o domínio.
Como Começar Hoje
Se você já está no Go 1.27, é só importar:
import (
jsonv2 "encoding/json/v2"
"encoding/json/jsontext"
)
func main() {
data := map[string]any{
"nome": "CodeInsider",
"posts": 500,
"tags": []string{"go", "json", "performance"},
}
// Marshal com v2 (defaults seguros)
out, err := jsonv2.Marshal(data)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(out))
// Unmarshal com streaming
reader := strings.NewReader(string(out))
var result map[string]any
if err := jsonv2.UnmarshalRead(reader, &result); err != nil {
log.Fatal(err)
}
}
Pra projetos existentes, a recomendação da equipe do Go é:
- Atualize pro Go 1.27 (o v1 já ganha performance de graça)
- Identifique os endpoints com mais tráfego JSON
- Migre esses endpoints pro v2 usando as options de compatibilidade
- Remova as flags de compatibilidade uma por vez, testando cada mudança
6 Anos Bem Gastos?
O primeiro rascunho do v2 surgiu no final de 2020, quando Daniel Marti publicou uma proposta inicial. Joe Tsai e outros membros da equipe core construíram um protótipo experimental. A discussão pública veio no final de 2023 (issue #63397), a proposta formal no início de 2025 (issue #71497), o pacote entrou como experimento no Go 1.25, e finalmente graduou pra estável no Go 1.27.
Seis anos. Pra um pacote de JSON.
Parece absurdo? Talvez. Mas quando você olha a quantidade de decisões de design envolvidas, a retrocompatibilidade perfeita com bilhões de linhas de código Go existente, e o fato de que o v1 interno agora roda em cima do v2 sem que ninguém precise mudar nada, o tempo começa a fazer sentido.
A equipe do Go poderia ter lançado um v2 rápido e sujo em 2021. Em vez disso, esperou até ter uma solução que resolve o problema de verdade, sem criar novos. Em um mundo onde todo framework JavaScript lança uma versão major a cada 6 meses e depreca a anterior, tem algo refrescante nisso.
O encoding/json/v2 está pronto, estável e disponível agora no Go 1.27. Se você programa em Go, não tem motivo pra não começar a usar. E se você não programa em Go, talvez essa seja uma boa hora pra considerar uma linguagem que leva 6 anos pra acertar um pacote em vez de 6 meses pra quebrar tudo.
Fonte de inspiração: A new Go API for JSON, publicado no blog oficial do Go.













