json/v2 is fixing many of Go's JSON quirks

by Dreams of Code
Share:
json/v2 is fixing many of Go's JSON quirks

json/v2 is Fixing Many of Go's JSON Quirks

Go 1.25 has finally dropped and with it we're seeing a number of new changes. Perhaps the most talked about of these are the various performance gains we're getting throughout the language including a new experimental garbage collector, faster slices, and improved JSON unmarshalling with the new experimental JSON/v2 package.

Whilst these performance improvements are pretty great, it's not what I would consider to be the most exciting part of this new JSON v2 package as it brings a number of other changes to both JSON marshalling and unmarshalling in Go. Changes that I believe are both overdue and that I and many other developers are going to be pretty happy with.

In this article, I'm going to show some of the more interesting changes coming to the new experimental JSON/v2 package found in Go 1.25, especially as there's a good chance that they'll become production in a future version of Go.

Null Slices & Maps: A Long-Overdue Fix

The first change that I want to talk about that's coming to the JSON/v2 package is the new formatting behavior when it comes to both nil slices and nil maps. Both of which are now rendered as either an empty array when it comes to the nil slice or an empty object when it comes to the nil map.

This differs to the existing behavior of the original encoding/json package. Here's how the original package handles nil values:

go
package main

import (
    "encoding/json"
    "fmt"
)

type Example struct {
    Slice []string `json:"slice"`
    Map   map[string]string `json:"map"`
}

func main() {
    data := Example{}
    result, _ := json.Marshal(data)
    fmt.Println(string(result))
    // Output: {"slice":null,"map":null}
}

However, if I change this to instead import the v2 package:

go
package main

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

type Example struct {
    Slice []string `json:"slice"`
    Map   map[string]string `json:"map"`
}

func main() {
    data := Example{}
    result, _ := json.Marshal(data)
    fmt.Println(string(result))
    // Output: {"slice":[],"map":{}}
}

This time you get back an empty array for the nil slice and an empty object for the nil map.

Why This Change Matters

Whilst this change may seem like a smaller one, it's actually kind of a big deal as the null rendering was something that would often trip up developers when working in Go. This is because when it comes to Go, it's actually very common to encounter nil slices due to the fact that nil is the zero value of a slice, but also because working with nil slices is actually pretty safe.

For example:

go
var nilSlice []int
fmt.Println(len(nilSlice)) // Output: 0

nilSlice = append(nilSlice, 1)
fmt.Println(nilSlice) // Output: [1]

Because of this, the Go documentation recommends preferring nil slices over instantiating an empty one as it's more performant to do so because it avoids a potentially unnecessary heap allocation. That being said, the documentation also mentions an exception to this rule if there's the need of rendering an empty array when it comes to JSON marshalling.

Therefore, in order to prevent any nils from being rendered as null, one would need to make sure that any nil slices are instantiated before performing JSON marshalling. However, this was often easier said than done, especially as you may not be responsible for the creation of the slice that you need to marshal.

Fortunately, this has now been resolved in the experimental encoding/json/v2 package, which will make it much easier to write Go APIs that conform to an API specification or just meet the expectations of a typical frontend.

json.Marshal Options: Fine-Tuning Your Output

One thing to consider with this change is that there may be times where the old behavior of rendering nils as null is actually desired. Fortunately, the original proposal for this change did take this requirement into consideration and has proposed a solution through another change being brought with the JSON/v2 package.

The json.Marshal function now supports an additional variadic parameter which can be used to pass in various different options. The two that have been added in order to support this previous null formatting behavior are:

  • FormatNilSliceAsNull
  • FormatNilMapsAsNull
go
data := Example{}
result, _ := json.Marshal(data, json.FormatNilSliceAsNull)

Additional Marshal Options

In addition to these two options, a number of other ones have also been added into the JSON v2 package in order to be able to fine-tune JSON marshalling outside of struct tags. Some of the more interesting options include:

  • StringifyNumbers - renders numbers as strings
  • EmitZeroStructFields - emits any zero-valued structs from being rendered
  • Multiline - renders JSON across multiple lines rather than on a single line making it a lot easier to read

For a full list of these options, you can refer to the encoding/json/v2 documentation.

Custom Marshallers: A Game-Changer

Perhaps one of the more interesting options is the new WithMarshalers option, which is used with perhaps my favorite new feature coming to the JSON v2 package: the MarshalFunc function.

This function allows you to create a custom JSON marshaller inline without you needing to implement the json.Marshaler interface on a custom type.

Boolean to Emoji Example

go
boolMarshaler := json.MarshalFunc(func(enc *jsontext.Encoder, val bool, opts json.Options) error {
    if val {
        return enc.WriteToken(jsontext.String("✓"))
    }
    return enc.WriteToken(jsontext.String("✗"))
})

result, _ := json.Marshal(true, json.WithMarshalers(boolMarshaler))
fmt.Println(string(result)) // Output: "✓"

Integer Squaring Example

In addition to booleans, we can also create a custom marshaller for other types thanks to the fact that this function makes use of generics:

go
intMarshaler := json.MarshalFunc(func(enc *jsontext.Encoder, val int, opts json.Options) error {
    squared := val * val
    return enc.WriteToken(jsontext.String(fmt.Sprintf("%d", squared)))
})

result, _ := json.Marshal(4, json.WithMarshalers(intMarshaler))
fmt.Println(string(result)) // Output: "16"

JoinMarshalers: Combining Multiple Marshallers

What if you want to use multiple custom marshallers at once? This is easily achieved by combining them into a single marshal value using the JoinMarshalers function:

go
type CustomStruct struct {
    Flag   bool `json:"flag"`
    Number int  `json:"number"`
}

combinedMarshaler := json.JoinMarshalers(boolMarshaler, intMarshaler)
data := CustomStruct{Flag: true, Number: 4}
result, _ := json.Marshal(data, json.WithMarshalers(combinedMarshaler))

This makes it a lot easier to reuse groups of marshallers across your entire project.

Custom Unmarshalers

In addition to the json.MarshalFunc, we also have the json.UnmarshalFunc, which basically works the same way, but is used to define any custom unmarshallers:

go
boolUnmarshaler := json.UnmarshalFunc(func(dec *jsontext.Decoder, val *bool, opts json.Options) error {
    token, err := dec.ReadToken()
    if err != nil {
        return err
    }

    str := token.String()
    if str == "yes" {
        *val = true
    } else if str == "no" {
        *val = false
    }
    return nil
})

var result bool
json.Unmarshal([]byte(`"yes"`), &result, json.WithUnmarshalers(boolUnmarshaler))
fmt.Println(result) // Output: true

Type Changes and Improvements

The new JSON v2 package brings about some other changes and improvements for JSON marshalling of different Go types:

Byte Arrays

Byte arrays are now formatted as base64 by default rather than as an array of integers, which is what they were rendered previously in version one. This brings the behavior to the same as byte slices, making the whole thing more consistent.

The format JSON Struct Tag

The JSON v2 package also brings a new struct tag called format which can be used to modify the formatting of different types.

Byte Slices and Byte Arrays

go
type Data struct {
    ByteSlice []byte `json:"data,format:array"`  // Renders as array of integers
    ByteArray [4]byte `json:"hash,format:hex"`   // Renders as hexadecimal string
    Encoded   []byte `json:"encoded,format:base64"` // Explicitly as base64
}

Duration Type

go
type Event struct {
    Duration time.Duration `json:"duration,format:sec"`  // Renders as seconds
    Timeout  time.Duration `json:"timeout,format:nano"`  // Renders as nanoseconds
    Interval time.Duration `json:"interval,format:iso8601"` // ISO8601 format
}

Time Type

go
type Schedule struct {
    Date     time.Time `json:"date,format:dateonly"`     // Date portion only
    Time     time.Time `json:"time,format:timeonly"`     // Time portion only
    Created  time.Time `json:"created,format:'2006-01-02 15:04:05'"` // Custom format
}

The default formatting is RFC 3339, which you can also explicitly set if you want.

Nil Slice and Map Control

go
type Response struct {
    Items  []string          `json:"items,format:emitnull"`  // Renders nil as null
    Params map[string]string `json:"params,format:emitempty"` // Renders nil as {}
}

MarshalWrite & UnmarshalRead: Streamlined I/O

The last big change I want to mention is two new functions being added to the JSON package: MarshalWrite and UnmarshalRead. These are basically replacements for the encoder and decoder types found in the original JSON package, with some improvements.

Original Approach

go
// Writing to file with encoder
file, _ := os.Create("data.json")
defer file.Close()
encoder := json.NewEncoder(file)
encoder.Encode(data)

// Reading from file with decoder
file, _ := os.Open("data.json")
defer file.Close()
decoder := json.NewDecoder(file)
var result MyStruct
decoder.Decode(&result)

New v2 Approach

go
// Writing to file
file, _ := os.Create("data.json")
defer file.Close()
json.MarshalWrite(file, data)

// Reading from file
file, _ := os.Open("data.json")
defer file.Close()
var result MyStruct
json.UnmarshalRead(file, &result)

Key Differences

There are a couple of differences to be aware of:

  1. The MarshalWrite function doesn't add a newline once it's finished writing out the JSON data, which the encode method of the encoder did before
  2. The UnmarshalRead function will now consume the entire IO reader and read until the end of file, differing from the decoder which would only read up to the next JSON value

If you want the old behaviors, you can still use the old encode and decode methods of the encoder and decoder, respectively. These have been moved to a new package called jsontext for more low-level streaming capabilities.

Conclusion

All in all, I think that the JSON v2 package is shaping up to be rather exciting. As someone who likes to use Go for building backend services and APIs, I'm perhaps now more excited for Go 1.26 and the new JSON package than I have been for any other Go release since 1.22, which is when we finally got decent HTTP routing inside of the Go standard library.

Overall, I think that this is a testament to the way that the Go language continues to evolve in a way that's both steady, but also feels intentional with each design decision.

Note: Remember to set the GOEXPERIMENT=jsonv2 environment variable when using the experimental JSON/v2 package.

Share: