Backend 5 min read 890 words

encoding/json/v2 is now stable in Go 1.27: what changes and how to migrate

ES
encoding/json/v2 is now stable in Go 1.27: what changes and how to migrate

The Go team has published A new Go API for JSON, announcing that the encoding/json/v2 and encoding/json/jsontext packages are stable as of Go 1.27. It’s the first time Go’s standard library ships a version 2 of a package as widely used as encoding/json, and the process has taken about six years: a GopherCon talk in late 2023, a formal proposal in early 2025 and a period as an experiment in Go 1.25.

A few weeks ago I wrote about another Go 1.27 change, HTTP/1 connection reuse, which improved performance without touching any code. This one is different: if you want to take advantage of it you do need to change something, although the Go team has made it possible to migrate gradually.

Why a v2

encoding/json is one of Go’s most used packages, and also one that had piled up the most complaints. Some of its behaviours are debatable, but they couldn’t be changed without breaking programs that have worked for years. The solution has been to release a new version with stricter defaults and leave the old one as it is.

What changes

These are the behaviour differences the article highlights:

Casev1v2
Invalid UTF-8Silently acceptedReturns an error
Duplicate keys in an objectKeeps the last oneReturns an error
nil slice or mapnull[] or {}
Field names when unmarshalingCase-insensitiveCase-sensitive
omitemptyOmits Go “empty” values (0, false, ""…)Omits whatever would encode as null, "", [] or {}
time.DurationEncoded as a number of nanosecondsReturns an error unless you specify the format

My favourite is the nil one. Anyone who has written an API in Go has at some point run into a JavaScript client that breaks because it expected an array and got null:

import (
    jsonv1 "encoding/json"
    "encoding/json/v2"
)

type User struct {
    Name  string   `json:"name"`
    Roles []string `json:"roles"`
}

u := User{Name: "Ana"}

b1, _ := jsonv1.Marshal(u) // {"name":"Ana","roles":null}
b2, _ := json.Marshal(u)   // {"name":"Ana","roles":[]}

The case-sensitivity one is the most likely to surprise you when migrating. With v1, a JSON document with "Name" fills the field tagged name; with v2 it doesn’t, and the field stays empty without any error. If your clients send field names with inconsistent casing, that’s the first thing to check.

And the duplicate keys one has more to do with security than it seems: if two parts of a system interpret the same JSON differently (one keeps the first key and the other the last), it opens the door to bypassing validations.

Options instead of global configuration

The other important change is a design one. In v1, customising anything meant using methods on the types or configuring an Encoder. In v2, options are just another argument to Marshal and Unmarshal, and they can be combined. This lets you, for example, use all of v2’s behaviour except one thing:

// Everything as in v2, but allowing duplicate keys
json.Marshal(v, json.DefaultOptionsV2(), jsontext.AllowDuplicateName(true))

// Everything as in v1, but rejecting duplicate keys
json.Marshal(v, jsonv1.DefaultOptionsV1(), jsontext.AllowDuplicateName(false))

There are also options to pass your own serialisation functions in the call, such as WithMarshalers and WithUnmarshalers, without having to define methods on the types. That’s very handy when the type isn’t yours and you can’t add a MarshalJSON to it.

The jsontext package handles the low-level part: reading and writing JSON tokens as a stream, without going through reflection. That’s what the MarshalJSONTo and UnmarshalJSONFrom methods are for, working directly on the stream instead of producing an intermediate []byte.

Performance

According to the article, Marshal is roughly on par with v1, sometimes slightly faster and sometimes slightly slower. Where it shows is Unmarshal, with improvements of up to 10x. They also mention a Kubernetes service that, after switching to UnmarshalJSONFrom, improved “by orders of magnitude”. And they promise more optimisations for Go 1.28.

How to migrate

The best part of the announcement is that you don’t have to migrate if you don’t want to. encoding/json v1 will be supported indefinitely, and it’s now implemented on top of v2 with all the compatibility options enabled. That means two things: your current code keeps working the same, and improvements that land in v2 (new methods, new tags) reach v1 as well.

For an existing project, this is what I’d do:

  1. Upgrade to Go 1.27 without changing anything and check that the tests pass.
  2. In new code, use encoding/json/v2 directly.
  3. In old code, migrate piece by piece, starting with Unmarshal on the paths where performance matters.
  4. If something breaks, use the options to get v1 behaviour back only at that point, instead of rolling back.

The Go team has published a migration guide in the package documentation with all the details, including the new struct tag options.

My takeaway

I think it’s a good example of how to evolve a widely used API without breaking anyone: a new version with better defaults, the old one built on top of the new one, and options to mix both during the transition. Six years is a long time, but for a package that’s in almost every Go program in the world, I’d rather have that caution than a quick change that forces you to review everything at once.

Source: A new Go API for JSON, Michael Pratt, Joe Tsai, Daniel Martí, Johan Brandhorst-Satzkorn, Roger Peppe, Chris Hines and Damien Neil, the Go blog.