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
}
func main() {
    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/ReadEncode/DecodeOptionsTagsCustom marshalingDefault behaviorPerformanceFinal thoughts

MarshalWrite and UnmarshalRead

In v1, you used Encoder to marshal to the io.Writer, and Decoder to unmarshal from the io.Reader:

func main() {
    // 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:

func main() {
    // 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:

  • MarshalWrite does not add a newline, unlike the old Encoder.Encode.
  • UnmarshalRead reads everything from the reader until it hits io.EOF, while the old Decoder.Decode only 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 → v2 json.MarshalEncode + jsontext.Encoder.
  • v1 Decoder.Decode → v2 json.UnmarshalDecode + jsontext.Decoder.

Streaming encoder:

func main() {
    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:

func main() {
    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:

  • FormatNilMapAsNull and FormatNilSliceAsNull define how to encode nil maps and slices.
  • MatchCaseInsensitiveNames allows matching Namename and the like.
  • Multiline expands JSON objects to multiple lines.
  • OmitZeroStructFields omits fields with zero values from the output.
  • SpaceAfterColon and SpaceAfterComma add a space after each : or ,
  • StringifyNumbers represents numeric types as strings.
  • WithIndent and WithIndentPrefix indent nested properties (note that the MarshalIndent function has been removed).

Each marshaling or unmarshaling function can take any number of options:

func main() {
    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:

func main() {
    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:

  • omitzero and omitempty to omit empty values.
  • string to represent numeric types as strings.
  • - to ignore fields.

And adds some more:

  • case:ignore or case:strict specify how to handle case differences.
  • format:template formats the field value according to the template.
  • inline flattens the output by promoting the fields of a nested object up to the parent.
  • unknown provides 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:

func main() {
    // 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:

func main() {
    // 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:

func main() {
    // Custom marshaler for bool values.
    boolMarshaler := json.MarshalToFunc(
        func(enc *jsontext.Encoder, val bool) error {
            if val {
                return enc.WriteToken(jsontext.String("✓"))
            }
            return enc.WriteToken(jsontext.String("✗"))
        },
    )

    // Custom marshaler for bool-like strings.
    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
        },
    )

    // 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 the FormatNilSliceAsNull option.
  • v1 marshals a nil map to null, v2 marshals to {}. You can change it with the FormatNilMapAsNull option.
  • v1 marshals a byte array to an array of numbers, v2 marshals to a base64-encoded string. You can change it with format:array and format:base64 tags.
  • v1 allows invalid UTF-8 characters within a string, v2 does not. You can change it with the AllowInvalidUTF8 option.

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, v1 uses an exact, case-sensitive match. You can change it with the MatchCaseInsensitiveNames option or case tag.
  • v1 allows duplicate fields in a object, v2 does not. You can change it with the AllowDuplicateNames option.

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/v2 package is experimental and must be enabled by setting GOEXPERIMENT=jsonv2 at build time. The package API is subject to change in future releases.
  • Turning on GOEXPERIMENT=jsonv2 makes the v1 json package 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.1proposal p.2json/v2jsontext

P.S. Want to learn more about Go? Check out my interactive book on concurrency

★ Subscribe to keep up with new posts.