Why the v2 Approach
Originally published in Japanese at https://zenn.dev/ymotongpoo/books/go-json-v2-history/viewer/80-why_v2.
The three principles for building a v2
The thinking behind placing a v2 in the standard library is laid out in “Evolving the Go Standard Library with math/rand/v2”, which Russ Cox wrote in May 2024. It names three principles.
The first is the import path convention. Under semantic import versioning, math/rand and math/rand/v2 are treated as distinct packages and can coexist in the same program. This is the technical precondition: adding a new package doesn’t make anyone import it without asking.
The second is respect for users. Changes must be well motivated and worth the cost of migration. The existing package is the starting point; you fix real problems, not remake things to taste.
The third is helping v1 users. Ideally, v2 becomes a thin wrapper around v1 (or the other way around), so that people who keep using v1 still receive fixes and performance improvements. Russ Cox also writes that this cannot always be achieved.
encoding/json/v2 satisfied that third principle in an inverted, stronger form: v1 became a wrapper around v2.
As a result, people who keep using v1 also run the fast unmarshal implementation. Bug fixes to encoding/json will land on the v2 side from now on. There is no need to maintain two implementations in parallel. The October 2020 README’s “the v1 implementation must be supported forever, so it would be convenient if v1 could be implemented on top of v2” was a statement about maintenance convenience, but it ended up resolving the compatibility problem as well.
The same blog post also says:
There will not be a glut of v2 packages in the next few Go releases. Instead, we will handle one package at a time… Many packages will not need a v2 at all.
Indeed, math/rand/v2 shipped in Go 1.22 and encoding/json/v2 in Go 1.27 — an interval of five releases.
Closing
On March 10, 2016, an issue was filed reporting that “the parser ignores the case of member names”; on May 13, 2026, the encoding/json/v2 proposal was accepted. Ten years and two months. Counting from when encoding/json entered Go 1.0, fourteen years.
You can call that period stagnation. The default was known to be dangerous, a fix patch had even been written, and still nothing moved.
Line up what happened during that period, though, and it looks different. Try a fix, measure it, give up. Write down a plan to rebuild under a different name. Build a prototype and run it in production services for three years. Open a discussion and rework the design through 96 comments. File a proposal and process a 229-person discussion by splitting it into sub-issues. Spend a year under an experiment flag with real users. Then flip the default, keeping the old implementation as an escape hatch during the switch. No stage was skipped. And in the end, it landed in a form where not a single line of existing code needs to change.
What makes the final state interesting is that compatibility stopped being an either-or. Put down DefaultOptionsV1() and override individual options, and you can specify any point you like between v1 and v2. What used to be a problem of choosing between “don’t break it” and “fix it” became a problem of deciding, per call, how far to lean.
At the same time, not everything was tidied up cleanly. The format tag was withdrawn just before release, and time.Duration shipped with no way to be serialized within the standard library. To touch that feature, you use the external module that was supposedly absorbed into the standard. An undecided proposal on the language side acted by removing one feature from the standard library. None of this appears in the release notes.
If you maintain a library of your own, you run into the same kind of problem. You know a behavior is wrong, but fixing it will break something that depends on it. The options at that point are not just to endure it or to break things. There is another path: build under a different name, then put the old one back on top of the new. The cost is high — in the case of encoding/json, six years passed from conception to formalization.
Back to the line from the beginning of this book:
json.Unmarshal([]byte(`{"NAME":"gopher"}`), &u)
Even in Go 1.27, with encoding/json this code puts gopher into Name. Exactly as it did 14 years ago. And the moment you rewrite it to encoding/json/v2, it stops doing so. You can now decide, call by call, which behavior you need. What those 14 years bought, I think, is less the strict JSON library itself than this state of being able to choose.
Issues remain. The typed struct tag proposal is still on hold, and whether the format tag returns to the standard is undecided. The GOEXPERIMENT=nojsonv2 option will eventually disappear too. When it does, that encode.go written in 2010 will be deleted from the tree. What will remain is the list of flags that gave names to 14 years of decisions.
encoding/json/v2 has been released, but I look forward to following what happens next with the features around it.