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/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
:
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 oldEncoder.Encode
.UnmarshalRead
reads everything from the reader until it hitsio.EOF
, while the oldDecoder.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
→ v2json.MarshalEncode
+jsontext.Encoder
. - v1
Decoder.Decode
→ v2json.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
andFormatNilSliceAsNull
define how to encode nil maps and slices.MatchCaseInsensitiveNames
allows matchingName
↔name
and the like.Multiline
expands JSON objects to multiple lines.OmitZeroStructFields
omits fields with zero values from the output.SpaceAfterColon
andSpaceAfterComma
add a space after each:
or,
StringifyNumbers
represents numeric types as strings.WithIndent
andWithIndentPrefix
indent nested properties (note that theMarshalIndent
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
andomitempty
to omit empty values.string
to represent numeric types as strings.-
to ignore fields.
And adds some more:
case:ignore
orcase: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 theFormatNilSliceAsNull
option. - v1 marshals a nil map to
null
, v2 marshals to{}
. You can change it with theFormatNilMapAsNull
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
andformat: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 orcase
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 settingGOEXPERIMENT=jsonv2
at build time. The package API is subject to change in future releases. - Turning on
GOEXPERIMENT=jsonv2
makes the v1json
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.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.