encoding/json/v2 no Go 1.27: o que muda e como migrar
O encoding/json/v2 ficou estável no Go 1.27: seis mudanças testadas lado a lado com o v1, handler HTTP mais seguro e como migrar sem quebrar quem usa a API.
O time do Go publicou em 4 de novembro de 2026 o texto "A new Go API for JSON", que oficializa uma mudança esperada havia anos: os pacotes encoding/json/v2 e encoding/json/jsontext saíram da fase experimental e agora são estáveis e fazem parte da biblioteca padrão do Go 1.27, lançado em 19 de agosto. O encoding/json de sempre continua funcionando e não vai ser removido, mas o v2 muda comportamentos que afetam qualquer API que recebe ou devolve JSON: chave duplicada vira erro, UTF-8 inválido vira erro, slice nil vira [] em vez de null e o nome do campo passa a diferenciar maiúsculas de minúsculas.
Em vez de repetir o anúncio, rodamos os dois pacotes lado a lado no Go 1.27.3 (a versão mais recente, de 19 de outubro) e anotamos a saída real de cada diferença. Abaixo estão os resultados, um handler HTTP que usa o v2 para ler o corpo da requisição, as opções que deixam você migrar aos poucos e o que medimos de desempenho.

O que entrou no Go 1.27
São dois pacotes com papéis separados. O jsontext cuida só da sintaxe: lê e escreve JSON como uma sequência de tokens e valores, em streaming de verdade e sem reflexão. O json/v2 fica por cima e faz a ponte entre JSON e tipos Go. A API básica é familiar para quem já usa o v1:
func Marshal(in any, opts ...Options) (out []byte, err error) func MarshalWrite(out io.Writer, in any, opts ...Options) error func Unmarshal(in []byte, out any, opts ...Options) error func UnmarshalRead(in io.Reader, out any, opts ...Options) error
A diferença está no último parâmetro. No v1, opções como DisallowUnknownFields só existiam no Decoder; no v2 toda chamada aceita opções, e elas descem até os métodos personalizados dos seus tipos. Outra mudança importante, descrita nas notas do Go 1.27: o próprio encoding/json agora roda em cima da implementação do v2, com as opções de compatibilidade ligadas. O comportamento foi preservado, mas o texto das mensagens de erro pode mudar. Se algum teste seu compara a string do erro, ele pode quebrar só por atualizar o Go, mesmo sem tocar em uma linha. Para quem tiver problema, a saída temporária é compilar com GOEXPERIMENT=nojsonv2, que volta a implementação antiga e deve ser removida numa versão futura.
Seis diferenças, com a saída real
O programa de teste decodifica a mesma entrada com encoding/json (importado como jsonv1) e com encoding/json/v2, usando estes tipos:
type Pedido struct {
ID int `json:"id"`
Status string `json:"status"`
Itens []string `json:"itens"`
Nota string `json:"nota,omitempty"`
}
type Conta struct {
Admin bool `json:"admin"`
}
Estes foram os resultados no Go 1.27.3, linux/amd64:
- Slice nil no Marshal.
Pedido{ID: 7, Status: "pago"}sai como"itens":nullno v1 e"itens":[]no v2. Para um front-end em JavaScript que fazpedido.itens.length, o v2 evita um erro. Para um consumidor que testaitens === null, o v2 quebra a lógica. - Chave duplicada. Com
{"admin":false,"admin":true}, o v1 aceita e fica com o último valor (admin=true, sem erro). O v2 recusa:jsontext: duplicate object member name "admin". O time do Go lembra no anúncio que esse tipo de ambiguidade já foi explorado em ataques, como no CVE-2017-12635 do CouchDB, em que dois analisadores liam valores diferentes da mesma chave repetida. - Maiúsculas no nome.
{"ADMIN":true}preenche o campoadminno v1, que compara nomes sem diferenciar caixa. No v2 o membro é ignorado e o campo ficafalse, sem erro nenhum. É a mudança mais silenciosa da lista: se algum cliente seu manda"Status"em vez de"status", o dado some sem aviso. - UTF-8 inválido. Uma string com o byte
0xFFno meio vira"pag�o"no v1, com o caractere de substituição e sem erro. O v2 respondejsontext: invalid UTF-8 within "/status" after offset 14, já apontando o campo. A RFC 8259 exige UTF-8 válido em JSON trocado entre sistemas, e o v1 corrompia o dado calado. Se o seu banco já recebeu texto com acento quebrado, o post sobre UTF-8 de ponta a ponta mostra onde mais olhar. - Lixo depois do objeto. Lendo
{"id":1,"status":"pago"} {"id":2}de umio.Reader, o padrão comum do v1,json.NewDecoder(r).Decode(&v), devolveid=1sem erro e ignora o resto. OUnmarshalReaddo v2 recusa:invalid character '{' after top-level value after offset 25. - time.Duration. No v1,
1500 * time.Millisecondsai como o número1500000000(nanossegundos). No v2 dá erro:cannot marshal from Go time.Duration within "/timeout": no default representation. Um detalhe que pegamos no teste: com o erro, oMarshaldevolveu um pedaço do JSON ({"timeout") junto com oerr. Nunca use os bytes quando o erro não for nil.
O omitempty mudou de sentido
Há uma sétima diferença que passa fácil na revisão de código. No v2, omitempty omite o campo quando ele sairia como um JSON vazio: null, "", [] ou {}. O número zero não é JSON vazio. No teste, um campo N int `json:"n,omitempty"` com valor 0 sumia da saída no v1 e aparecia como "n":0 no v2. Para omitir pelo valor zero do Go, a tag certa agora é omitzero, que também respeita um método IsZero() se o tipo tiver (é o caso de time.Time). Atenção ao contrário: omitzero só omite slice nil; um slice vazio mas não nil continua saindo como [].
Duration sem a tag format
Durante a fase experimental, dava para escrever json:"timeout,format:units". Essa opção foi removida antes da versão estável, como as notas do Go 1.27 registram, e no 1.27.3 a tag gera erro em tempo de execução. Restam dois caminhos que testamos. Para manter o número em nanossegundos do v1, passe jsonv1.FormatDurationAsNano(true), que devolveu {"timeout":1500000000}. Para um formato legível, registre funções para o tipo, mesmo sem ser dono dele:
var opcoesAPI = json.JoinOptions(
json.WithMarshalers(json.MarshalFunc(func(d time.Duration) ([]byte, error) {
return json.Marshal(d.String())
})),
json.WithUnmarshalers(json.UnmarshalFunc(func(b []byte, d *time.Duration) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
v, err := time.ParseDuration(s)
*d = v
return err
})),
)
Com isso, json.Marshal(cfg, opcoesAPI) devolveu {"timeout":"1.5s"}, e {"timeout":"2m30s"} foi lido como 2m30s. Essa escolha de formato faz parte do contrato da API e precisa estar documentada, como discutimos em Swagger não substitui o contrato.
Lendo o corpo de uma requisição com o v2
O lugar onde o v2 mais ajuda no dia a dia é a entrada da API. Este handler usa UnmarshalRead com RejectUnknownMembers e um limite de tamanho:
func criar(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var p NovoPedido
if err := json.UnmarshalRead(r.Body, &p, json.RejectUnknownMembers(true)); err != nil {
http.Error(w, "JSON inválido: "+err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.MarshalWrite(w, map[string]any{"cliente": p.Cliente, "itens": p.Itens})
}
Testamos com httptest cinco corpos diferentes:
{"cliente":"ana","itens":["cabo"]}: 201, pedido criado.{"cliente":"ana"}: 201, e a resposta já sai com"itens":[], nãonull.{"cliente":"ana","itens":[],"desconto":100}: 400,unknown object member name "desconto". Um campo que o cliente tenta empurrar e que a API não conhece não passa mais em silêncio.{"cliente":"ana","cliente":"bia"}: 400, nome duplicado.{"cliente":"ana"}{"cliente":"bia"}: 400, conteúdo depois do objeto.
No v1, os três últimos teriam passado sem DisallowUnknownFields e sem uma checagem manual do fim do corpo. Quem trata dinheiro e quantidade em JSON também deve conferir como o campo ausente vira valor padrão; o post sobre nulo que vira zero mostra esse problema em várias linguagens.
Como migrar sem quebrar quem consome a sua API
O guia oficial de migração começa dizendo que ninguém é obrigado a migrar. Para quem quer, ele propõe três caminhos, conforme o risco:
- Tudo de uma vez: trocar o import para
encoding/json/v2, rodar os testes e ajustar o que quebrar. Serve para ferramentas internas e serviços pequenos. - Opção por opção: trocar as chamadas para o v2 passando
jsonv1.DefaultOptionsV1(), o que dá exatamente o comportamento antigo, e depois desligar uma compatibilidade de cada vez. Opções passadas depois sobrescrevem as anteriores, entãojson.Marshal(v, jsonv1.DefaultOptionsV1(), json.FormatNilSliceAsNull(false))é o v1 com slice nil virando[]. - jsonsplit em produção: o pacote go-json-experiment/jsonsplit chama v1 e v2, devolve o resultado do v1 e registra as diferenças, inclusive qual opção causou cada uma. O custo é fazer o trabalho em dobro; dá para amostrar só parte das chamadas.
Para o caminho opção por opção, estas são as chaves que conferimos no Go 1.27.3, cada uma devolvendo o comportamento antigo de um item da lista acima:
json.FormatNilSliceAsNull(true)ejson.FormatNilMapAsNull(true): slice e map nil voltam a sair comonull.json.MatchCaseInsensitiveNames(true): volta a aceitar"ADMIN"no campoadmin. Também existe a tagcase:ignorepor campo.jsontext.AllowDuplicateNames(true): aceita chave repetida. Repare no plural: o exemplo do anúncio escreveAllowDuplicateName, e esse nome não compila no 1.27.3 (undefined: jsontext.AllowDuplicateName).jsontext.AllowInvalidUTF8(true): aceita UTF-8 inválido.jsonv1.FormatDurationAsNano(true): Duration em nanossegundos.
Na prática, a ordem que recomendamos para um serviço em produção é: primeiro, rodar os testes de integração no Go 1.27 sem mudar nada, porque o v1 já usa a implementação nova por baixo. Depois, migrar a entrada (requisições recebidas) para o v2, onde as recusas novas protegem você. Por último, a saída, onde null virando [] pode quebrar clientes antigos; ali vale passar FormatNilSliceAsNull(true) até confirmar com quem consome.
Desempenho: o que medimos e o que o anúncio promete
O time do Go diz que o Marshal do v2 fica no mesmo nível do v1 e que o Unmarshal é bem mais rápido, com ganhos de até 10 vezes em alguns benchmarks. O maior salto vem de tipos que implementam UnmarshalJSON chamando Unmarshal de novo por dentro, padrão que deixava o v1 quadrático; o anúncio cita um serviço do Kubernetes que melhorou em ordens de grandeza ao trocar para UnmarshalJSONFrom.
No nosso teste, um array de 2.000 objetos simples (cerca de 138 KB), sem métodos personalizados, o Unmarshal do v2 ficou entre 7% e 17% mais rápido que o do v1, em três rodadas de go test -bench numa máquina de 8 núcleos compartilhada (por volta de 1,3 ms contra 1,5 ms por operação). Lembre que, no Go 1.27, o v1 já roda sobre o código novo, então essa comparação não mostra o ganho em relação ao encoding/json do Go 1.26. Para payloads comuns, conte com uma melhora modesta; para tipos com decodificação personalizada recursiva, o ganho pode ser grande.
Vale migrar agora?
- Código novo: sim. Comece direto no v2; os padrões são mais seguros e a API com opções é mais simples.
- API pública com muitos clientes: migre a leitura das requisições primeiro e trate a saída com as opções de compatibilidade.
- Biblioteca usada por terceiros: não há pressa. O v1 e o v2 entendem os métodos um do outro (
MarshalJSONfunciona no v2 eMarshalJSONTofunciona no v1), então quem depende de você pode migrar no próprio ritmo. - Testes que comparam mensagens de erro: revise-os antes de subir o Go 1.27 no CI, porque o texto mudou até no v1.
Em resumo: atualizar para o Go 1.27 não quebra o seu encoding/json, mas trocar o import para o v2 muda seis comportamentos que vale testar um a um. As recusas novas (chave duplicada, UTF-8 inválido, campo desconhecido, lixo no fim) são ganho de segurança na entrada da API. O cuidado maior fica na saída, com null virando [] e o novo sentido do omitempty.
Testado no Go 1.27.3 (linux/amd64) em 5 de novembro de 2026. Fontes: anúncio no blog do Go, notas do Go 1.27, guia de migração, histórico de versões e a documentação do encoding/json. Equipe DevBras.