Go JSON v2 Migration: What Breaks in Go 1.27

Lobsters Hottest News

Summary

Go 1.27 ships `encoding/json/v2` in the standard library and quietly re-implements the old `encoding/json` package on top of it, changing error strings and flipping defaults such as nil slices becoming `[]`, case-sensitive field matching, duplicate key rejection, and `time.Duration` no longer marshaling when v2 is imported directly.

<p><a href="https://lobste.rs/s/par7rv/go_json_v2_migration_what_breaks_go_1_27">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 10/03/26, 12:48 PM

# Go JSON v2 in Go 1.27: what breaks when you migrate from encoding/json Source: [https://importstatic.com/go/go-json-v2-migration](https://importstatic.com/go/go-json-v2-migration) 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](https://go.dev/doc/go1.27)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: Go ``` 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: Terminal ``` == 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\. 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: Go ``` // 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) } } ``` Terminal ``` === 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`: Terminal ``` 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](https://go.dev/doc/jsonv2-migration)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: Go ``` 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) ``` Terminal ``` 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`](https://pkg.go.dev/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: 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](https://importstatic.com/go/go-synctest-testing-concurrent-code)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\. 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\.](https://importstatic.com/about#corrections)

Similar Articles

Go 1.27 is released - The Go Programming Language

Lobsters Hottest

Go 1.27 is released with major enhancements including generic methods, performance improvements, and new standard library features like encoding/json/v2 and post-quantum cryptography support.

Go 1.27 Release Notes

Lobsters Hottest

Go 1.27 release introduces generic methods and other language and toolchain updates while maintaining compatibility with previous versions.