DEV Community

Cover image for Go 1.27's JSON v2 is already under your encoding/json
ImportStatic
ImportStatic

Posted on Originally published at importstatic.com

Go 1.27's JSON v2 is already under your encoding/json

Originally published on ImportStatic.

Go 1.27 put encoding/json/v2 in the standard library, and the part most people will miss is that you are already running it. Since Go 1.27, the old encoding/json package is implemented on top of the v2 engine with a set of compatibility options switched on. Your output should not change, but your error strings can, and the moment you change an import to encoding/json/v2 the defaults flip: nil slices become [], field names match case-sensitively, duplicate keys are rejected and time.Duration stops marshaling at all.

What Go 1.27 changed even if you never import v2

The Go 1.27 release notes describe two new packages and one swapped engine:

  • encoding/json/v2 is the new semantic API: Marshal, Unmarshal, MarshalWrite, UnmarshalRead, MarshalEncode and UnmarshalDecode, each taking variadic Options.
  • encoding/json/jsontext is the lower-level syntactic layer, with an Encoder and Decoder that work on tokens and raw values.

The notes then say that encoding/json "is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ." The v1 API stays supported and nobody is required to migrate. If something does break after the upgrade, building with GOEXPERIMENT=nojsonv2 restores the original code, but the release notes say that opt-out "is expected to be removed in a future release."

So the first migration step costs nothing: upgrade to 1.27 and run your tests. The one category to look for is tests that compare err.Error() against a fixed string. Those can fail without any behaviour change, and the fix is to assert on error types such as *json.SyntaxError or *json.UnmarshalTypeError instead.

Go JSON v2 vs v1, side by side

The package documentation for encoding/json has a "Migrating to v2" section listing every behaviour difference and the option that controls it. Reading a list is one thing; seeing your own structs change is another. Here is the struct we used:

type User struct {
    Name    string   `json:"name"`
    Tags    []string `json:"tags"`
    Admin   bool     `json:"admin,omitempty"`
    Retries int      `json:"retries,omitempty"`
}
Enter fullscreen mode Exit fullscreen mode

The program marshals and unmarshals the same values with jsonv1 "encoding/json" and "encoding/json/v2" and prints both. This is the real output:

== nil slice and omitempty on bool/int ==
v1   {"name":"ana","tags":null}
v2   {"name":"ana","tags":[],"admin":false,"retries":0}
== case-insensitive field names ==
v1   "bob" err=<nil>
v2   "" err=<nil>
== duplicate names ==
v1   "mallory" err=<nil>
v2   "alice" err=jsontext: duplicate object member name "name"
== invalid UTF-8 ==
v1   "caf�" err=<nil>
v2   "" err=jsontext: invalid UTF-8 within "/name" after offset 12
== time.Duration ==
v1   {"name":"backup","timeout":90000000000}
v2   error: json: unable to marshal from Go time.Duration within "/timeout": no default representation
v2+  {"name":"backup","timeout":90000000000}
== HTML escaping ==
v1   {"q":"<a&b>"}
v2   {"q":"<a&b>"}
v2+  {"q":"<a&b>"}
Enter fullscreen mode Exit fullscreen mode

Going through them in order of how likely they are to hurt.

Nil slices and maps. v1 writes null for a nil slice or map; v2 writes [] and {}. That is friendlier to JavaScript clients and a silent contract change for anyone who checks === null. json.FormatNilSliceAsNull(true) and json.FormatNilMapAsNull(true) bring the old output back.

omitempty means something else. The first line shows "admin":false,"retries":0 reappearing. In v1, omitempty drops false, 0, nil pointers and nil interfaces. In v2 it drops a field only if it would encode as JSON null, "", {} or []. The documentation says the two agree for strings, slices, maps and arrays, and that existing uses on a bool, number, pointer or interface "should migrate to specifying omitzero instead", which works the same in both versions.

Case-insensitive field matching is gone. v1 happily put {"NAME":"bob"} into the Name field. v2 matches names exactly, so the field stays empty and, notice, no error is returned. If your clients send inconsistent casing, you will find out from missing data, not from logs. json.MatchCaseInsensitiveNames(true) restores the loose match for a whole call; the case:ignore tag option does it per field.

Duplicate keys are an error. v1 let the last "name" win, which is how {"name":"alice","name":"mallory"} became mallory. The v2 documentation's security section explains why this matters when two services parse the same request differently. v2 rejects it unless you pass jsontext.AllowDuplicateNames(true). One detail from our run: after the error, the destination already held "alice". Treat the struct as garbage once Unmarshal returns an error.

Invalid UTF-8 is an error. v1 replaced the bad byte with U+FFFD; v2 refuses, unless jsontext.AllowInvalidUTF8(true) is set. If you ingest data from old Latin-1 systems, this is the one that will show up in production.

time.Duration has no representation. In v2, a time.Duration "has no default representation and results in a SemanticError". That is the v2 error line. The v2+ line passes jsonv1.FormatDurationAsNano(true), an option that lives in the v1 package, and gets the old nanosecond integer back.

HTML escaping is off. v2 uses minimal escaping, so <a&b> goes out as is. If you embed JSON in HTML <script> blocks, keep jsontext.EscapeForHTML(true).

The documentation lists a few more that our structs did not hit: Go arrays must now be unmarshaled from a JSON array of exactly the same length, byte arrays (not slices) become Base64 strings, maps are no longer sorted when marshaled unless you pass json.Deterministic(true), the string tag option only applies to values that encode as numbers, unmarshaling null always zeroes the target, and malformed struct tags are reported as errors at runtime instead of being ignored.

Tags that give the same JSON under v1 and v2

You do not have to pick a side per call site. Most differences can be pinned on the type, so the same struct encodes identically whichever package touches it. This version of the struct passes a test that marshals with both and compares bytes:

// Portable keeps the same JSON under v1 and v2.
type Portable struct {
    Name    string   `json:"name,case:ignore"`
    Tags    []string `json:"tags,omitempty"`
    Admin   bool     `json:"admin,omitzero"`
    Retries int      `json:"retries,omitzero"`
}

func TestPortableSameBytes(t *testing.T) {
    for _, p := range []Portable{{Name: "ana"}, {Name: "bo", Tags: []string{"x"}, Admin: true, Retries: 3}} {
        b1, err := jsonv1.Marshal(p)
        if err != nil {
            t.Fatal(err)
        }
        b2, err := json.Marshal(p)
        if err != nil {
            t.Fatal(err)
        }
        if string(b1) != string(b2) {
            t.Errorf("v1 %s != v2 %s", b1, b2)
        }
        t.Logf("%s", b2)
    }
}
Enter fullscreen mode Exit fullscreen mode
=== RUN   TestPortableSameBytes
    main_test.go:31: {"name":"ana"}
    main_test.go:31: {"name":"bo","tags":["x"],"admin":true,"retries":3}
--- PASS: TestPortableSameBytes (0.00s)
Enter fullscreen mode Exit fullscreen mode

omitempty on the slice makes nil and empty both disappear, which sidesteps the null versus [] question. omitzero replaces omitempty on the bool and int. case:ignore keeps accepting "NAME" under v2, and a second test confirmed it decodes into Name.

While you are in there, v2 has an option v1 never had. json.RejectUnknownMembers(true) turns a typo or an unexpected field into an error that wraps json.ErrUnknownName:

json: cannot unmarshal JSON string into Go main.Portable: unknown object member name "role"
Enter fullscreen mode Exit fullscreen mode

That is worth having on any request body that reaches an authorization check.

If you used v2 under GOEXPERIMENT=jsonv2 on Go 1.25 or 1.26, check your tags again. The release notes list changes made before the package became final: the format and unknown tag options were removed, the DiscardUnknownMembers option and SkipFunc were removed, and the inline tag option was renamed embed. Blog posts written during the experiment still show format: tags that will not work on 1.27.

Migrating one option at a time with DefaultOptionsV1

For a large codebase, the safe path is the one the official migration guide calls option-by-option. Change call sites to the v2 functions but pass jsonv1.DefaultOptionsV1(), which the guide calls "a trivial and safe change". Then switch individual behaviours to v2 by adding options after it, since later options override earlier ones:

b, err = json.Marshal(u, jsonv1.DefaultOptionsV1())
show("v2v1", b, err)
b, err = json.Marshal(u, jsonv1.DefaultOptionsV1(), json.FormatNilSliceAsNull(false))
show("mix", b, err)
Enter fullscreen mode Exit fullscreen mode
v2v1 {"name":"ana","tags":null}
mix  {"name":"ana","tags":[]}
Enter fullscreen mode Exit fullscreen mode

The first call is byte-for-byte v1. The second is v1 except for nil slices. Each option you flip is a small, reviewable change, and when the list of overrides covers everything you can drop DefaultOptionsV1() entirely.

For servers where you cannot predict every payload from tests, the guide points to github.com/go-json-experiment/jsonsplit. It has call modes such as CallBothButReturnV1, which runs both implementations, returns the v1 result and reports any difference, so production traffic tells you which options you need before you switch to CallBothButReturnV2 or OnlyCallV2. The guide warns that running both "will approximately double the cost of marshaling". Use it for the migration window, not permanently.

What we measured: faster unmarshal, slower marshal

The release notes say "marshal performance is broadly at parity with the previous implementation, while unmarshal performance is significantly faster." We checked that on one payload: a 500-element array of a struct with an int64, a string, a float, a three-element string slice, a two-entry map[string]string and a nested struct, about 79 KB of JSON. The benchmark calls plain encoding/json, run twelve times on the default 1.27.1 build and six times with GOEXPERIMENT=nojsonv2, on an Apple M4 Pro:

encoding/json on Go 1.27.1 Unmarshal Allocs Marshal Allocs
v2 engine (default) ~695 µs 5,011 ~336 µs 2,003
old engine (nojsonv2) ~920 µs 7,018 ~241 µs 2,502

Times are rounded medians; the unmarshal runs were noisy (537 to 878 µs on the new engine), while the allocation counts were identical on every run. Unmarshal came out about 25% faster with 29% fewer allocations, which matches the release notes. Marshal took about 39% longer on this payload, which does not match "broadly at parity". One payload on one machine is not a verdict, but it is enough to say: if marshaling is on your hot path, benchmark it on 1.27 with and without nojsonv2 before you roll out.

If you are upgrading for the other Go 1.27 changes as well, the new synctest.Sleep and in-memory test server are worth a look in the same pass.


Originally published on ImportStatic. This article was drafted with AI assistance; every claim was checked against the sources linked in it and the code samples were compiled and run.

Top comments (2)

Collapse
 
nube_colectiva_nc profile image
Nube Colectiva •

Good guide to JSON V2 in Go 👍🏼

Collapse
 
suppdevbot profile image
DEV SUPPORTS •
You need to verify your account.
Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to