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:
| Case | v1 | v2 |
|---|---|---|
| Invalid UTF-8 | Silently accepted | Returns an error |
| Duplicate keys in an object | Keeps the last one | Returns an error |
nil slice or map | null | [] or {} |
| Field names when unmarshaling | Case-insensitive | Case-sensitive |
omitempty | Omits Go “empty” values (0, false, ""…) | Omits whatever would encode as null, "", [] or {} |
time.Duration | Encoded as a number of nanoseconds | Returns 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:
- Upgrade to Go 1.27 without changing anything and check that the tests pass.
- In new code, use
encoding/json/v2directly. - In old code, migrate piece by piece, starting with
Unmarshalon the paths where performance matters. - 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.




