JSON evolution in Go: from v1 to v2
The second version of the json package coming in Go 1.25 is a big update, and it has a lot of breaking changes. The v2 package adds new features, fixes API issues and behavioral flaws, and boosts performance. Let's take a look at what's changed!
All examples are interactive, so you can read and practice at the same time.
The basic use case with Marshal and Unmarshal stays the same. This code works in both v1 and v2:
type Person struct {
Name string
Age int
}
alice := Person{Name: "Alice", Age: 25}
// Marshal Alice.
b, err := json.Marshal(alice)
fmt.Println(string(b), err)
// Unmarshal Alice.
err = json.Unmarshal(b, &alice)
fmt.Println(alice, err)
{"Name":"Alice","Age":25} <nil>
{Alice 25} <nil>
But the rest is pretty different. Let's go over the main changes from v1.
Write/Read • Encode/Decode • Options • Tags • Custom marshaling • Default behavior • Performance • Final thoughts
MarshalWrite and UnmarshalRead
In v1, you used Encoder to marshal to the io.Writer, and Decoder to unmarshal from the io.Reader:
// Marshal Alice.
alice := Person{Name: "Alice", Age: 25}
out := new(strings.Builder) // io.Writer
enc := json.NewEncoder(out)
enc.Encode(alice)
fmt.Println(out.String())
// Unmarshal Bob.
in := strings.NewReader(`{"Name":"Bob","Age":30}`) // io.Reader
dec := json.NewDecoder(in)
var bob Person
dec.Decode(&bob)
fmt.Println(bob)
{"Name":"Alice","Age":25}
{Bob 30}
From now on, I'll leave out error handling to keep things simple.
In v2, you can use MarshalWrite and UnmarshalRead directly, without any intermediaries:
// Marshal Alice.
alice := Person{Name: "Alice", Age: 25}
out := new(strings.Builder)
json.MarshalWrite(out, alice)
fmt.Println(out.String())
// Unmarshal Bob.
in := strings.NewReader(`{"Name":"Bob","Age":30}`)
var bob Person
json.UnmarshalRead(in, &bob)
fmt.Println(bob)
{"Name":"Alice","Age":25}
{Bob 30 false}
They're not interchangeable, though:
MarshalWritedoes not add a newline, unlike the oldEncoder.Encode.UnmarshalReadreads everything from the reader until it hitsio.EOF, while the oldDecoder.Decodeonly reads the next JSON value.
MarshalEncode and UnmarshalDecode
The Encoder and Decoder types have been moved to the new jsontext package, and their interfaces have changed significantly (to support low-level streaming encode/decode operations).
You can use them with json functions to read and write JSON streams, similar to how Encode and Decode worked before:
- v1
Encoder.Encode→ v2json.MarshalEncode+jsontext.Encoder. - v1
Decoder.Decode→ v2json.UnmarshalDecode+jsontext.Decoder.
Streaming encoder:
people := []Person{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
{Name: "Cindy", Age: 15},
}
out := new(strings.Builder)
enc := jsontext.NewEncoder(out)
for _, p := range people {
json.MarshalEncode(enc, p)
}
fmt.Print(out.String())
{"Name":"Alice","Age":25}
{"Name":"Bob","Age":30}
{"Name":"Cindy","Age":15}
Streaming decoder:
in := strings.NewReader(`
{"Name":"Alice","Age":25}
{"Name":"Bob","Age":30}
{"Name":"Cindy","Age":15}
`)
dec := jsontext.NewDecoder(in)
for {
var p Person
// Decodes one Person object per call.
err := json.UnmarshalDecode(dec, &p)
if err == io.EOF {
break
}
fmt.Println(p)
}
{Alice 25}
{Bob 30}
{Cindy 15}
Unlike UnmarshalRead, UnmarshalDecode works in a fully streaming way, decoding one value at a time with each call, instead of reading everything until io.EOF.
Options
Options configure marshaling and unmarshaling functions with specific features:
FormatNilMapAsNullandFormatNilSliceAsNulldefine how to encode nil maps and slices.MatchCaseInsensitiveNamesallows matchingName↔nameand the like.Multilineexpands JSON objects to multiple lines.OmitZeroStructFieldsomits fields with zero values from the output.SpaceAfterColonandSpaceAfterCommaadd a space after each:or,StringifyNumbersrepresents numeric types as strings.WithIndentandWithIndentPrefixindent nested properties (note that theMarshalIndentfunction has been removed).
Each marshaling or unmarshaling function can take any number of options:
alice := Person{Name: "Alice", Age: 25}
b, _ := json.Marshal(
alice,
json.OmitZeroStructFields(true),
json.StringifyNumbers(true),
jsontext.WithIndent(" "),
)
fmt.Println(string(b))
{
"Name": "Alice",
"Age": "25"
}
You can also combine options with JoinOptions:
alice := Person{Name: "Alice", Age: 25}
opts := json.JoinOptions(
jsontext.SpaceAfterColon(true),
jsontext.SpaceAfterComma(true),
)
b, _ := json.Marshal(alice, opts)
fmt.Println(string(b))
{"Name": "Alice", "Age": 25}
See the complete list of options in the documentation: some are in the json package, others are in the jsontext package.
Tags
v2 supports field tags defined in v1:
omitzeroandomitemptyto omit empty values.stringto represent numeric types as strings.-to ignore fields.
And adds some more:
case:ignoreorcase:strictspecify how to handle case differences.format:templateformats the field value according to the template.inlineflattens the output by promoting the fields of a nested object up to the parent.unknownprovides a "catch-all" for the unknown fields.
Here's an example demonstrating inline and format:
type Person struct {
Name string `json:"name"`
// Format date as yyyy-mm-dd.
BirthDate time.Time `json:"birth_date,format:DateOnly"`
// Inline address fields into the Person object.
Address `json:",inline"`
}
type Address struct {
Street string `json:"street"`
City string `json:"city"`
}
func main() {
alice := Person{
Name: "Alice",
BirthDate: time.Date(2001, 7, 15, 12, 35, 43, 0, time.UTC),
Address: Address{
Street: "123 Main St",
City: "Wonderland",
},
}
b, _ := json.Marshal(alice, jsontext.WithIndent(" "))
fmt.Println(string(b))
}
{
"name": "Alice",
"birth_date": "2001-07-15",
"street": "123 Main St",
"city": "Wonderland"
}
And unknown:
type Person struct {
Name string `json:"name"`
// Collect all unknown Person fields
// into the Data field.
Data map[string]any `json:",unknown"`
}
func main() {
src := `{
"name": "Alice",
"hobby": "adventure",
"friends": [
{"name": "Bob"},
{"name": "Cindy"}
]
}`
var alice Person
json.Unmarshal([]byte(src), &alice)
fmt.Println(alice)
}
{Alice map[friends:[map[name:Bob] map[name:Cindy]] hobby:adventure]}
Custom marshaling
The basic use case for custom marshaling using Marshaler and Unmarshaler interfaces stays the same. This code works in both v1 and v2:
// A custom boolean type represented
// as "✓" for true and "✗" for false.
type Success bool
func (s Success) MarshalJSON() ([]byte, error) {
if s {
return []byte(`"✓"`), nil
}
return []byte(`"✗"`), nil
}
func (s *Success) UnmarshalJSON(data []byte) error {
// Data validation omitted for brevity.
*s = string(data) == `"✓"`
return nil
}
func main() {
// Marshaling.
val := Success(true)
data, err := json.Marshal(val)
fmt.Println(string(data), err)
// Unmarshaling.
src := []byte(`"✓"`)
err = json.Unmarshal(src, &val)
fmt.Println(val, err)
}
"✓" <nil>
true <nil>
However, the Go standard library documentation recommends using the new MarshalerTo and UnmarshalerFrom interfaces instead (they work in a purely streaming way, which can be much faster):
// A custom boolean type represented
// as "✓" for true and "✗" for false.
type Success bool
func (s Success) MarshalJSONTo(enc *jsontext.Encoder) error {
if s {
return enc.WriteToken(jsontext.String("✓"))
}
return enc.WriteToken(jsontext.String("✗"))
}
func (s *Success) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
// Data validation omitted for brevity.
tok, err := dec.ReadToken()
*s = tok.String() == `✓`
return err
}
func main() {
// Marshaling.
val := Success(true)
data, err := json.Marshal(val)
fmt.Println(string(data), err)
// Unmarshaling.
src := []byte(`"✓"`)
err = json.Unmarshal(src, &val)
fmt.Println(val, err)
}
"✓" <nil>
true <nil>
Even better, you're no longer limited to just one way of marshaling a specific type. Now you can use custom marshalers and unmarshalers whenever you need, with the generic MarshalFunc and UnmarshalFunc functions.
func MarshalFunc[T any](fn func(T) ([]byte, error)) *Marshalers
func UnmarshalFunc[T any](fn func([]byte, T) error) *Unmarshalers
For example, you can marshal a bool value to ✓ or ✗ without creating a custom type:
// Custom marshaler for bool values.
boolMarshaler := json.MarshalFunc(
func(val bool) ([]byte, error) {
if val {
return []byte(`"✓"`), nil
}
return []byte(`"✗"`), nil
},
)
// Pass the custom marshaler to Marshal
// using the WithMarshalers option.
val := true
data, err := json.Marshal(val, json.WithMarshalers(boolMarshaler))
fmt.Println(string(data), err)
"✓" <nil>
And unmarshal ✓ or ✗ to bool:
// Custom unmarshaler for bool values.
boolUnmarshaler := json.UnmarshalFunc(
func(data []byte, val *bool) error {
*val = string(data) == `"✓"`
return nil
},
)
// Pass the custom unmarshaler to Unmarshal
// using the WithUnmarshalers option.
src := []byte(`"✓"`)
var val bool
err := json.Unmarshal(src, &val, json.WithUnmarshalers(boolUnmarshaler))
fmt.Println(val, err)
true <nil>
There are also MarshalToFunc and UnmarshalFromFunc functions for creating custom marshalers. They're similar to MarshalFunc and UnmarshalFunc, but they work with jsontext.Encoder and jsontext.Decoder instead of byte slices.
func MarshalToFunc[T any](fn func(*jsontext.Encoder, T) error) *Marshalers
func UnmarshalFromFunc[T any](fn func(*jsontext.Decoder, T) error) *Unmarshalers
You can combine marshalers using JoinMarshalers (and unmarshalers using JoinUnmarshalers). For example, here's how you can marshal both booleans (true/false) and "boolean-like" strings (on/off) to ✓ or ✗, while keeping the default marshaling for all other values.
First we define a custom marshaler for bool values:
// Marshals boolean values to ✓ or ✗..
boolMarshaler := json.MarshalToFunc(
func(enc *jsontext.Encoder, val bool) error {
if val {
return enc.WriteToken(jsontext.String("✓"))
}
return enc.WriteToken(jsontext.String("✗"))
},
)
Then we define a custom marshaler for bool-like strings:
// Marshals boolean-like strings to ✓ or ✗.
strMarshaler := json.MarshalToFunc(
func(enc *jsontext.Encoder, val string) error {
if val == "on" || val == "true" {
return enc.WriteToken(jsontext.String("✓"))
}
if val == "off" || val == "false" {
return enc.WriteToken(jsontext.String("✗"))
}
// SkipFunc is a special type of error that tells Go to skip
// the current marshaler and move on to the next one. In our case,
// the next one will be the default marshaler for strings.
return json.SkipFunc
},
)
Finally, we combine marshalers with JoinMarshalers and pass them to the marshaling function using the WithMarshalers option:
// Combine custom marshalers with JoinMarshalers.
marshalers := json.JoinMarshalers(boolMarshaler, strMarshaler)
// Marshal some values.
vals := []any{true, "off", "hello"}
data, err := json.Marshal(vals, json.WithMarshalers(marshalers))
fmt.Println(string(data), err)
["✓","✗","hello"] <nil>
Isn't that cool?
Default behavior
v2 changes not only the package interface, but the default marshaling/unmarshaling behavior as well.
Some notable marshaling differences include:
- v1 marshals a nil slice to
null, v2 marshals to[]. You can change it with theFormatNilSliceAsNulloption. - v1 marshals a nil map to
null, v2 marshals to{}. You can change it with theFormatNilMapAsNulloption. - v1 marshals a byte array to an array of numbers, v2 marshals to a base64-encoded string. You can change it with
format:arrayandformat:base64tags. - v1 allows invalid UTF-8 characters within a string, v2 does not. You can change it with the
AllowInvalidUTF8option.
Here's an example of the default v2 behavior:
type Person struct {
Name string
Hobbies []string
Skills map[string]int
Secret [5]byte
}
func main() {
alice := Person{
Name: "Alice",
Secret: [5]byte{1, 2, 3, 4, 5},
}
b, _ := json.Marshal(alice, jsontext.Multiline(true))
fmt.Println(string(b))
}
{
"Name": "Alice",
"Hobbies": [],
"Skills": {},
"Secret": "AQIDBAU="
}
And here's how you can enforce v1 behavior:
type Person struct {
Name string
Hobbies []string
Skills map[string]int
Secret [5]byte `json:",format:array"`
}
func main() {
alice := Person{
Name: "Alice",
Secret: [5]byte{1, 2, 3, 4, 5},
}
b, _ := json.Marshal(
alice,
json.FormatNilMapAsNull(true),
json.FormatNilSliceAsNull(true),
jsontext.Multiline(true),
)
fmt.Println(string(b))
}
{
"Name": "Alice",
"Hobbies": null,
"Skills": null,
"Secret": [
1,
2,
3,
4,
5
]
}
Some notable unmarshaling differences include:
- v1 uses case-insensitive field name matching, v2 uses an exact, case-sensitive match. You can change it with the
MatchCaseInsensitiveNamesoption orcasetag. - v1 allows duplicate fields in a object, v2 does not. You can change it with the
AllowDuplicateNamesoption.
Here's an example of the default v2 behavior (case-sensitive):
type Person struct {
FirstName string
LastName string
}
func main() {
src := []byte(`{"firstname":"Alice","lastname":"Zakas"}`)
var alice Person
json.Unmarshal(src, &alice)
fmt.Printf("%+v\n", alice)
}
{FirstName: LastName:}
And here's how you can enforce v1 behavior (case-insensitive):
type Person struct {
FirstName string
LastName string
}
func main() {
src := []byte(`{"firstname":"Alice","lastname":"Zakas"}`)
var alice Person
json.Unmarshal(
src, &alice,
json.MatchCaseInsensitiveNames(true),
)
fmt.Printf("%+v\n", alice)
}
{FirstName:Alice LastName:Zakas}
See the complete list of behavioral changes in the documentation.
Performance
When marshaling, v2 performs about the same as v1. It's faster with some datasets, but slower with others. However, unmarshaling is much better: v2 is 2.7x to 10.2x faster than v1.
Also, you can get significant performance benefits by switching from regular MarshalJSON and UnmarshalJSON to their streaming alternatives — MarshalJSONTo and UnmarshalJSONFrom. According to the Go team, it allows to convert certain O(n²) runtime scenarios into O(n). For example, switching from UnmarshalJSON to UnmarshalJSONFrom in the k8s OpenAPI spec made it about 40 times faster.
See the jsonbench repo for benchmark details.
Final thoughts
Phew! That's a lot to take in. The v2 package has more features and is more flexible than v1, but it's also a lot more complex, especially given the split into json/v2 and jsontext subpackages.
A couple of things to keep in mind:
- As of Go 1.25, the
json/v2package is experimental and can be enabled by settingGOEXPERIMENT=jsonv2at build time. The package API is subject to change in future releases. - Turning on
GOEXPERIMENT=jsonv2makes the v1jsonpackage use the new JSON implementation, which is faster and supports some options for better compatibility with the old marshaling and unmarshaling behavior.
Finally, here are some links to learn more about the v2 design and implementation:
proposal p.1 • proposal p.2 • json/v2 • jsontext
P.S. Want to learn more about Go? Check out my interactive book on concurrency
★ Subscribe to keep up with new posts.