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. Useerrors.Aswhere you can. - Go provides an escape hatch. Building with
GOEXPERIMENT=nojsonv2restores 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.
| Change | v1 | v2 | How it fails | Keep v1 behavior with |
|---|---|---|---|---|
| Field name matching | Case-insensitive | Exact case | Silent: field stays empty | json.MatchCaseInsensitiveNames(true) or tag case:ignore |
omitempty | Omits false, 0, nil, empty array, map or string | Omits values that encode as null, "", {} or [] | Silent: extra or missing keys | jsonv1.OmitEmptyWithLegacySemantics(true) |
| Nil slice or map | null | [] or {} | Silent: consumers see a new value | json.FormatNilSliceAsNull(true), json.FormatNilMapAsNull(true) |
| Map order | Sorted keys | No guarantee | Silent, and intermittent | json.Deterministic(true) |
| HTML characters | Escaped | Escaped only where the grammar requires | Silent, with a security angle | jsontext.EscapeForHTML(true), jsontext.EscapeForJS(true) |
| Byte arrays | Array of numbers | Base64 string | Silent: wire format changes | jsonv1.FormatByteArrayAsArray(true) |
Pointer-receiver MarshalJSON | Only if addressable | Always called | Silent: output changes | jsonv1.CallMethodsWithLegacySemantics(true) |
| Unmarshal into existing values | Merges | Merges mainly for objects; null always zeroes | Silent: stale or cleared fields | jsonv1.MergeWithLegacySemantics(true) |
time.Duration | Nanoseconds number | Runtime error | Loud | jsonv1.FormatDurationAsNano(true) |
| Invalid UTF-8 | Replaced with U+FFFD | Error | Loud, on real input only | jsontext.AllowInvalidUTF8(true) |
| Duplicate names | Allowed, last one wins | Error | Loud, on real input only | jsontext.AllowDuplicateNames(true) |
| Array length | Any length | Must match | Loud | jsonv1.UnmarshalArrayFromAnyLength(true) |
string tag, bad struct tags | Ignored at runtime | Error | Loud | Better 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 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. One short email when a new Claude Code, AI agent, or iOS workflow guide ships.<script>& into
Free: the coding agent setup I use
Get the next tested guide

Comments