Go's new JSON package is finished. On November 4 the Go team published a long post declaring encoding/json/v2 and its lower level partner encoding/json/jsontext stable and generally available in Go 1.27, after about six years of design work. The headline most people will repeat is the speed: benchmarks of up to 10x on unmarshal. The part that matters for your production service is smaller and less fun. Change one import line and your code still compiles, your tests may still pass, and some of your JSON will be different.

This guide covers what shipped, what has already changed if you run Go 1.27 without touching a single import, which v2 differences fail silently, and how to pick a migration path. If you only have ten minutes, read the table and the checklist.

What shipped, in plain terms

Go 1.27 arrived in August 2026 and added two standard library packages. encoding/json/v2 is the one you call: Marshal, MarshalWrite, MarshalEncode, Unmarshal, UnmarshalRead and UnmarshalDecode, all of which accept variadic options. encoding/json/jsontext handles syntax only, with a streaming Encoder and Decoder and no reflection.

The packages first appeared as an experiment in Go 1.25, behind GOEXPERIMENT=jsonv2. They no longer need that flag. Go's documentation is clear that migration is optional: encoding/json stays, it is covered by the Go 1 compatibility promise, and the two packages interoperate. A type with a v2 MarshalerTo method still uses it when called through v1's Marshal, and v2 still honors v1 MarshalJSON methods in packages you have not migrated.

Why a new major version at all? Several v1 behaviors could not be fixed without breaking the compatibility promise. Case-insensitive field matching, which the Go team calls surprising and a security concern as well as a performance cost, is the clearest example. Accepting duplicate object names and invalid UTF-8 are two more.

You may already be running the new code

This is the detail that deserves more attention than the speed numbers. Go 1.27's release notes say encoding/json is now backed by the v2 implementation. Every v1 call goes through v2 with a full set of compatibility options switched on. So if you moved to Go 1.27 this summer and never imported anything new, you have been using the v2 engine for weeks.

The notes promise that marshaling and unmarshaling behavior is preserved, with one stated exception: "the exact text of error messages may differ." That has two practical effects.

  • Any test or log alert that matches a JSON error string can break after the toolchain upgrade. Search for it: grep -rn "json:" --include="*_test.go" . and look for assertions on error text. Use errors.As where you can.
  • Go provides an escape hatch. Building with GOEXPERIMENT=nojsonv2 restores the original v1 implementation. The release notes say this opt-out is expected to be removed in a future release and ask people who need it to file an issue. Treat it as a temporary fix and open that issue if you use it.

One outside comparison is worth knowing about. The writer at huizhou92.com tested 13 behaviors between Go 1.26.2 and a Go 1.27 release candidate and found 12 identical. The one difference was at byte level: invalid UTF-8 now comes out as raw U+FFFD bytes in place of the ASCII escape sequence. Both parse to the same string, but a golden file comparing bytes would notice.

A note on the code below

The v1 outputs in this article came from running the code on Go 1.24.7. No Go 1.27 toolchain was available where this was written, so the v2 outputs follow Go's documentation and were not compiled. Run the snippets on your own toolchain before you rely on them.

The changes, sorted by how quietly they fail

Go's v1 package documentation lists 16 differences in its "Migrating to v2" section. Some return an error straight away, which is the good case because your tests or logs will show it. The dangerous ones return nil and different data.

Changev1v2How it failsKeep v1 behavior with
Field name matchingCase-insensitiveExact caseSilent: field stays emptyjson.MatchCaseInsensitiveNames(true) or tag case:ignore
omitemptyOmits false, 0, nil, empty array, map or stringOmits values that encode as null, "", {} or []Silent: extra or missing keysjsonv1.OmitEmptyWithLegacySemantics(true)
Nil slice or mapnull[] or {}Silent: consumers see a new valuejson.FormatNilSliceAsNull(true), json.FormatNilMapAsNull(true)
Map orderSorted keysNo guaranteeSilent, and intermittentjson.Deterministic(true)
HTML charactersEscapedEscaped only where the grammar requiresSilent, with a security anglejsontext.EscapeForHTML(true), jsontext.EscapeForJS(true)
Byte arraysArray of numbersBase64 stringSilent: wire format changesjsonv1.FormatByteArrayAsArray(true)
Pointer-receiver MarshalJSONOnly if addressableAlways calledSilent: output changesjsonv1.CallMethodsWithLegacySemantics(true)
Unmarshal into existing valuesMergesMerges mainly for objects; null always zeroesSilent: stale or cleared fieldsjsonv1.MergeWithLegacySemantics(true)
time.DurationNanoseconds numberRuntime errorLoudjsonv1.FormatDurationAsNano(true)
Invalid UTF-8Replaced with U+FFFDErrorLoud, on real input onlyjsontext.AllowInvalidUTF8(true)
Duplicate namesAllowed, last one winsErrorLoud, on real input onlyjsontext.AllowDuplicateNames(true)
Array lengthAny lengthMust matchLoudjsonv1.UnmarshalArrayFromAnyLength(true)
string tag, bad struct tagsIgnored at runtimeErrorLoudBetter to remove the tag

In these snippets jsonv1 is an import alias for encoding/json, and json is encoding/json/v2.

Case matching can lose data with no error

This one worries me most, because unknown members are ignored by default. When a name stops matching, v2 treats it as an unknown member and moves on.

type User struct {
	UserName string `json:"user_name"`
	Email    string
}

var u User
err := json.Unmarshal([]byte(`{"USER_NAME":"ana","email":"a@b.c"}`), &u)
fmt.Printf("%+v %v\n", u, err)

On v1 this printed {UserName:ana Email:a@b.c} <nil>. Under v2's exact matching both fields should stay empty and err should still be nil. The risk lands on untagged fields fed by clients that send lowercase keys, and on partners who spell a key differently from your tag.

Fix it per field with a tag, or per call with an option:

type User struct {
	UserName string `json:"user_name,case:ignore"`
	Email    string `json:"email"`
}

err := json.Unmarshal(data, &u, json.MatchCaseInsensitiveNames(true))

If you control the input, a better fix is to make the mismatch loud. json.RejectUnknownMembers(true) turns an unknown member into an error, which is a useful setting in a test even when production stays lenient. Note that case:ignore also ignores dashes and underscores, so user-name would match too.

omitempty now means something else

In v1 the rule looked at the Go value. In v2 it looks at the JSON that would be written. The same tag produces different keys in both directions.

type Order struct {
	ID       string
	Total    int  `json:"total,omitempty"`
	Paid     bool `json:"paid,omitempty"`
	Shipping struct {
		City string `json:"city,omitempty"`
	} `json:"shipping,omitempty"`
}

b, _ := json.Marshal(Order{ID: "a1"})

v1 printed {"ID":"a1","shipping":{}}. Following v2's documented rule, 0 and false are not empty JSON values, so they stay, and {} is empty, so it goes: {"ID":"a1","total":0,"paid":false}.

If what you meant was "skip the zero value", say so with omitzero. It omits a field when it is the zero value or when its IsZero() bool method returns true, and new code should reach for it first. omitzero works in v1 as well, so you can change the tag before you migrate and keep the output stable.

Nil slices change what clients receive

The Go migration guide uses a pet with no nicknames. "Nicknames":null becomes "Nicknames":[]. In most cases that is an improvement, since consumers no longer need a null check. The trouble comes from consumers who treat the two values differently. A PATCH handler might read null as "leave it alone" and [] as "clear the list". A mobile client might show an empty state for one and hide a section for the other. Check every API your service exposes to code you do not control.

Map order breaks things that compare bytes

v1 sorts map keys when marshaling. v1 printed {"a":2,"m":3,"z":1} for a map written in the order z, a, m. v2 makes no such promise. Normal API traffic will not care. Golden file tests will start failing now and then, and anything that hashes or signs marshaled JSON, such as cache keys, ETags and request signatures, can produce a different value for the same data. Those need an explicit option:

b, err := json.Marshal(v, json.Deterministic(true))

HTML escaping is off, so check your templates

v1 turned <script>& into

Free: the coding agent setup I use

The Coding Agent Lab holds the agent configurations I actually work with. The Free tier costs $0, so you can see how I build before you buy anything.

Get the free kit My own product on Gumroad. Max and Ultra are the paid tiers.

Get the next tested guide

One short email when a new Claude Code, AI agent, or iOS workflow guide ships.

Subscribe free Opens my Gumroad subscribe page. Unsubscribe in one click.

Comments