New And Improved Formula! (JSON v2 in Go 1.25)
A while ago, when I opened another tube of my favorite toothpaste, my brain jumped into alert mode: The toothpaste looked entirely different from the one in the previous tube! Did the factory make a mistake and filled functionless filler material into the tube? Did criminals replace the toothpaste with poison to blackmail the company?
A few moments of web research later, it was clear that the company simply changed their formula. They just forgot or didn't bother to communicate that change on their tubes (like, with those blatant callouts with a neon-orange background saying something like “New and improved formula!”). Sigh.
The Go team, as always, acts much more careful and communicative about changes to Go. The upcoming json/v2 package is a great example: In Go 1.25, it is only available as an experiment (set GOEXPERIMENT=jsonv2 at build time to get all the json v2 goodness), and documentation is readily available at gotip.
This is one of the first packages in the standard library to carry a “v2” tag (math/rand/v2 being the first one), which means breaking changes, and this begs the question: why?
Go and JSON aren't a great team. Go is a strictly typed language whereas JSON is kind of sloppy: dynamic typing, dynamic structures, ambiguous numbers, unstructured data, mixed-type arrays, etc. The original json package has a difficult time catering to real-world use cases, and fixing or improving the package couldn't be done in a backward-compatible way, and so, the Go team decided to address the accumulated issues in a separate v2 package.
So what are the changes in v2?
Let me summarize the changes here—briefly, because this is just a Spotlight and I already wrote this lengthy intro above just as if I was paid by word count. So here we go:
In a nutshell, Go's JSON package v2 introduces more secure and stricter defaults to remove or reduce many of the ambiguities that come with JSON marshaling and unmarshaling while maintaining backward compatibility through options.
Here are the key differences and their motivations:
Security improvements
- Stricter UTF-8 validation: v2 errors out on invalid UTF-8 instead of silently replacing it, to prevent data corruption
- No duplicate JSON keys allowed: v2 rejects duplicate object keys to prevent parsing ambiguities
- Less HTML escaping: v2 uses minimal escaping by default, to prevent unnecessary character mangling
More consistent behavior
- Case-sensitive field matching: v2 uses exact name matches instead of case-insensitive. This eliminates ambiguous field mapping
- Predictable
omitempty: v2 omits fields based on JSON representation rather than Go zero values, which is more intuitive - Consistent null handling: v2 always zeros out values when unmarshaling JSON null
- Better merge semantics: v2 has clearer rules for when to merge versus replace during unmarshaling
Cleaner defaults
- Nil slices/maps: v2 marshals them as empty arrays/objects instead of null, which is a more natural JSON representation
- Byte arrays: v2 embraces the standard practice of using Base64 encoding rather than number arrays
- No deterministic map ordering: v2 allows natural map iteration; as Go maps have (intentionally!) no particular key ordering, this change results in better performance
- Stricter type validation: v2 reports structural errors at runtime instead of silently ignoring them
Breaking changes (for good reasons)
- Array length matching: v2 requires exact length match when unmarshaling arrays, to prevent data loss
- Limited
stringtag: v2 only allows it on numbers - No default time.Duration: v2 requires explicitly choosing a format; there's no default representation anymore
New functions
The above differences affect the functions that json and json/v2 have in common. But json/v2 also comes with some new functionality. Core marshaling and unmarshaling gets these additional functions:
Marshal Functions
MarshalEncode(): Writes a Go value directly into a jsontext.Encoder (a streaming encoder), allowing for streaming output without intermediate byte slice allocation
MarshalWrite(): Serializes a Go value directly to an io.Writer, combining marshaling and writing in a single operation
Unmarshal Functions
UnmarshalDecode(): Reads and unmarshals the next JSON value from a jsontext.Decoder, enabling streaming input processing
UnmarshalRead(): Reads JSON data from an io.Reader and unmarshals it into a Go value, consuming the entire input until EOF
Marshaler/Unmarshaler Management
There are more functions, which I'll list only briefly here; feel free to peek into encoding/json/v2 for more information:
- Marshalers.JoinMarshalers(): Combines multiple marshaler functions
- Marshalers.MarshalFunc(): Creates type-specific marshaler from a function
- Marshalers.MarshalToFunc(): Creates type-specific marshaler using an encoder
- Unmarshalers.JoinUnmarshalers(): Combines multiple unmarshaler functions
- Unmarshalers.UnmarshalFunc(): Creates type-specific unmarshaler from a function
- Unmarshalers.UnmarshalFromFunc(): Creates type-specific unmarshaler using a decoder
Migration tip
The json/v2 documentation recommends using only v2 going forward (after v2 leaves experimental state), so you might ask how to migrate to v2 without headache. Here's a tip: v2 can mimic specific v1 behavior through legacy options. You can gradually migrate by mixing v1 and v2 options. For example, this code would make the Marshal function behave like v1 but with v2’s case-sensitive matching enabled:
package main
import (
jsonv1 "encoding/json"
jsonv2 "encoding/json/v2"
)
func main() {
v := struct{ a int }{a: 1}
jsonv2.Marshal(v, jsonv1.DefaultOptionsV1(), jsonv2.MatchCaseInsensitiveNames(false))
}
(Run this code with Go 1.25 (RC1 or later) and the environment setting GOEXPERIMENT=jsonv2)
So rather than turning the JSON-handling parts of your code into a large construction site, you can gradually introduce v2 behavior in manageable iterative steps.
