How v1 Sits on Top of v2

Originally published in Japanese at https://zenn.dev/ymotongpoo/books/go-json-v2-history/viewer/50-v1_on_v2.

Putting v1 on top of v2

In Go 1.27, encoding/json/v2 and encoding/json/jsontext became official packages. At the same time, encoding/json was reimplemented on top of v2.

From the release notes:

The encoding/json package is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ.

What the October 2020 README described — “it would be convenient if v1 could be implemented in terms of v2” — has come true exactly as written.

At first glance, this looks like nothing but good news. It reads as if people who keep writing json.Marshal get the benefit of v2’s faster implementation without doing anything, and no migration is needed.

The full list of legacy behavior flags

So how is v1’s behavior preserved? The encoding/json documentation answers that.

As mentioned, the entirety of v1 is implemented in terms of v2, where options are implicitly specified to opt into legacy behavior. For example, [Marshal] directly calls [jsonv2.Marshal] with [DefaultOptionsV1].

The contents of that DefaultOptionsV1 are defined in an internal package of the standard library. Open internal/jsonflags/flags.go and you find this:

// Marshal and Unmarshal flags (for v1).
const (
	_ Bools = (maxArshalV2Flag >> 1) << iota

	CallMethodsWithLegacySemantics  // marshal or unmarshal
	FormatByteArrayAsArray          // marshal or unmarshal
	FormatBytesWithLegacySemantics  // marshal or unmarshal
	FormatDurationAsNano            // marshal or unmarshal
	MatchCaseSensitiveDelimiter     // marshal or unmarshal
	MergeWithLegacySemantics        // unmarshal
	OmitEmptyWithLegacySemantics    // marshal
	ParseBytesWithLooseRFC4648      // unmarshal
	ParseTimeWithLooseRFC3339       // unmarshal
	ReportErrorsWithLegacySemantics // marshal or unmarshal
	StringifyWithLegacySemantics    // marshal or unmarshal
	UnmarshalAnyWithRawNumber       // unmarshal; for internal use by jsonv1.Decoder.UseNumber
	UnmarshalArrayFromAnyLength     // unmarshal

	maxArshalV1Flag
)

Fourteen years’ worth of “things we wanted to fix but couldn’t” line up here, each with a name.

FormatDurationAsNano is the true identity of the issue Russ Cox closed in 2017, saying this fundamental part would not change. Some readers will also recognize ParseTimeWithLooseRFC3339 and ParseBytesWithLooseRFC4648: RFC compliance was loose, and inputs that should have been rejected were accepted. UnmarshalArrayFromAnyLength lets a three-element JSON array be unmarshaled into a five-element Go array. OmitEmptyWithLegacySemantics signals that the meaning of omitempty itself changed in v2: the criterion moved from Go’s type system to JSON’s type system.

The WithLegacySemantics suffix on 6 of the 13 flags means “the semantics of the past.” Merely selecting v1’s behavior could have been named V1Semantics, but they chose Legacy. What the 2020 README from chapter 3 called “certain behaviors that are now considered mistakes” has become an identifier in the standard library.

None of these behaviors were deleted or hidden. They are preserved as v1 behavior, in a form where each one can be specified individually when needed.

Fine-tuning behavior with DefaultOptionsV1

What this row of flags actually does becomes clear when you run it.

package main

import (
	jsonv1 "encoding/json"
	"encoding/json/jsontext"
	jsonv2 "encoding/json/v2"
	"fmt"
)

type Doc struct {
	Tags  []string          `json:"tags"`
	Attrs map[string]string `json:"attrs"`
	Body  string            `json:"body"`
}

func main() {
	d := Doc{Body: "<b>hi</b>"} // Tags and Attrs are both nil

	b1, _ := jsonv1.Marshal(d)
	fmt.Printf("v1                       : %s\n", b1)

	b2, _ := jsonv2.Marshal(d)
	fmt.Printf("v2                       : %s\n", b2)

	b3, _ := jsonv2.Marshal(d, jsonv1.DefaultOptionsV1())
	fmt.Printf("v2 + DefaultOptionsV1    : %s\n", b3)
	fmt.Printf("v1 と一致するか          : %v\n", string(b1) == string(b3))

	// Start from v1 and flip only HTML escaping to the v2 side
	b4, _ := jsonv2.Marshal(d, jsonv1.DefaultOptionsV1(), jsontext.EscapeForHTML(false))
	fmt.Printf("v1 - HTMLエスケープ      : %s\n", b4)

	// Start from v2 and flip only the nil-slice representation to the v1 side
	b5, _ := jsonv2.Marshal(d, jsonv2.FormatNilSliceAsNull(true))
	fmt.Printf("v2 + nilスライスをnullに : %s\n", b5)
}
v1                       : {"tags":null,"attrs":null,"body":"\u003cb\u003ehi\u003c/b\u003e"}
v2                       : {"tags":[],"attrs":{},"body":"<b>hi</b>"}
v2 + DefaultOptionsV1    : {"tags":null,"attrs":null,"body":"\u003cb\u003ehi\u003c/b\u003e"}
v1 と一致するか          : true
v1 - HTMLエスケープ      : {"tags":null,"attrs":null,"body":"<b>hi</b>"}
v2 + nilスライスをnullに : {"tags":null,"attrs":{},"body":"<b>hi</b>"}

The third line matches v1’s output byte for byte. You call the v2 API, and the result is exactly identical to v1.

Then look at lines 4 and 5: you can flip a single behavior toward v2 starting from v1, or a single behavior toward v1 starting from v2. Options specified later take precedence, so the pattern of placing DefaultOptionsV1() first and then overriding individual options works.

For 14 years, there were only two choices: keep using v1 as it was, or accept that v1 would break. Now you can specify any point between v1 and v2. Compatibility is no longer an either-or decision.

The encoding/json documentation explicitly presents this usage as a means of incremental migration.

jsonv1.Marshal(v)
// Default v1 behavior

jsonv2.Marshal(v, jsonv1.DefaultOptionsV1())
// Semantically equivalent to jsonv1.Marshal

jsonv2.Marshal(v, jsonv1.DefaultOptionsV1(), jsontext.AllowDuplicateNames(false))
// Mostly v1, but adopts v2's behavior of rejecting duplicate names

jsonv2.Marshal(v, jsonv1.CallMethodsWithLegacySemantics(true))
// Mostly v2, but adopts v1's method-calling behavior

jsonv2.Marshal(v)
// Default v2 behavior