Go JSON v2 in Go 1.27: what breaks when you migrate from encoding/json
Go 1.27 ships encoding/json/v2. We ran v1 and v2 side by side on the same structs: here is every output that changed and the option or tag that fixes it.
Published Oct 3, 2026 9 min read By ImportStatic Editors
On this page
- What Go 1.27 changed even if you never import v2
- Go JSON v2 vs v1, side by side
- Tags that give the same JSON under v1 and v2
- Migrating one option at a time with DefaultOptionsV1
- What we measured: faster unmarshal, slower marshal
- Common questions
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.
Go 1.27.0 was released on 2026-08-19 and the current point release, as of 2026-10-03, is Go 1.27.1. Every program and output below was run on go1.27.1 darwin/arm64. The benchmark section also uses GOEXPERIMENT=nojsonv2 to compare against the old implementation.
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/v2is the new semantic API:Marshal,Unmarshal,MarshalWrite,UnmarshalRead,MarshalEncodeandUnmarshalDecode, each taking variadicOptions.encoding/json/jsontextis the lower-level syntactic layer, with anEncoderandDecoderthat 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:
GoCopy
type User struct {
Name string `json:"name"`
Tags []string `json:"tags"`
Admin bool `json:"admin,omitempty"`
Retries int `json:"retries,omitempty"`
}
The program marshals and unmarshals the same values with jsonv1 "encoding/json" and "encoding/json/v2" and prints both. This is the real output:
TerminalCopy
== 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>"}
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:
GoCopy
// 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)
}
}
TerminalCopy
=== RUN TestPortableSameBytes
main_test.go:31: {"name":"ana"}
main_test.go:31: {"name":"bo","tags":["x"],"admin":true,"retries":3}
--- PASS: TestPortableSameBytes (0.00s)
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:
TerminalCopy
json: cannot unmarshal JSON string into Go main.Portable: unknown object member name "role"
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:
GoCopy
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)
TerminalCopy
v2v1 {"name":"ana","tags":null}
mix {"name":"ana","tags":[]}
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.
Common questions
Do I have to migrate to encoding/json/v2 in Go 1.27?
No. The Go team says the v1 encoding/json API will continue to be supported and users are not required to migrate. In Go 1.27, though, v1 is implemented on top of the v2 engine, so error message text can change even if you never touch your imports.
How do I turn off the new JSON implementation in Go 1.27?
Build with GOEXPERIMENT=nojsonv2. That restores the original v1 implementation behind encoding/json. The release notes say this opt-out is expected to be removed in a future release, so treat it as a stopgap and file an issue for whatever made you need it.
Why does json/v2 fail to marshal time.Duration?
In v2 a time.Duration has no default representation, so Marshal returns a SemanticError. Pass the encoding/json option FormatDurationAsNano(true) to get the v1 behaviour of encoding nanoseconds as a JSON number.
Is omitempty the same in json v2?
Only for strings, slices, maps and arrays. v2 omits a field when it would encode as JSON null, an empty string, an empty object or an empty array, so false and 0 are no longer omitted. Use omitzero on bools, numbers, pointers and interfaces; it behaves the same in v1 and v2.
Is encoding/json/v2 faster than v1?
The Go 1.27 release notes say unmarshal is significantly faster and marshal is broadly at parity. In our benchmark on Go 1.27.1, unmarshal was about 25% faster with 29% fewer allocations, while marshal took about 39% longer on that payload, so measure your own hot paths.
Sources checked for this article (6)
- Go 1.27 Release Notes - New v2 and jsontext packages, v1 now backed by v2, GOEXPERIMENT=nojsonv2, performance statement, tag options removed or renamed during the experiment
- encoding/json package documentation, Go 1.27.1 - Migrating to v2 section: full list of behaviour differences and the option controlling each, DefaultOptionsV1
- encoding/json/v2 package documentation, Go 1.27.1 - Tag options (omitzero, omitempty, string, case, embed), RejectUnknownMembers, Duration has no default representation, security considerations
- encoding/json/v2 Migration Guide - All-at-once, option-by-option and jsonsplit migration approaches
- jsonsplit package documentation, version of 14 August 2026 - Call modes for running v1 and v2 side by side
- Go release history - Go 1.27.0 released 2026-08-19, Go 1.27.1 released 2026-09-01
ImportStatic articles are researched and drafted with AI assistance, checked against primary sources, and every code sample is compiled and run before publishing. Found a mistake? Here is how corrections work.