The Origins of v2

Originally published in Japanese at https://zenn.dev/ymotongpoo/books/go-json-v2-history/viewer/30-v2_draft.

The draft of late 2020

mvdan wrote his fix and gave it up in March 2020. In the latter half of that year, mvdan began drafting a design for v2.

The official Go blog writes that the inability to fix the current package’s problems was the trigger. The draft is public under the title encoding/json v2 draft, and it opens with this disclaimer.

Please note that these are mostly my personal opinions as one of the maintainers of encoding/json. In no way are they a formal proposal (yet) or endorsed by the Go project.

While framed as personal opinion, the acknowledgments list names such as Philip Pearl, Matt Layher, Dave Cheney, Chris Hines, Roger Peppe, and Joe Tsai—a formidable lineup: the author of a series dissecting encoding/json’s slowness, the author of a fast JSON tokenizer, a contributor since the day Go was released, and people who had kept improving serialization and performance outside the standard library. One of them, Go team member Joe Tsai, would make the first commit of the v2 prototype just a few weeks later.

The body of the document organizes the points to fix into the following four sections.

  • Buffering is unavoidable
  • Marshaler and Unmarshaler cannot take options
  • MarshalJSON always allocates
  • Decoder.Decode invites misuse

The unfixable problems are lined up with their issue numbers, but what deserves attention is the design premise placed before them.

We want to stick to the design principles of the standard library: correctness over performance by default, no unsafe, and no extra steps such as code generation. These rule out the majority of third-party JSON library designs.

There was still something to learn from those third-party libraries. A “Previous work” section cites the allocation-reducing API design of json-iterator/go, Phil Pearl’s analysis of Marshaler’s performance limits, and Dave Cheney’s fast tokenizer implementation. The stance: their design principles would not be adopted, but their framing of the problems would be consulted.

The document also has a surviving comment.

another: make a list of v1 semantics that we want to bury/hide from the new API, and only keep working from the old API entrypoints

AllArshalV1Flags, introduced later in this book, is that one line made real.

The preface says one more thing.

This document does not intend to encourage yet another competitor to encoding/json. However, a fork is likely to happen in the future to allow experimenting with these changes.

An implementation that started with the syntax layer

A few weeks after the draft, implementation began.

The first commit to github.com/go-json-experiment/json is dated October 23, 2020, by Joe Tsai. He was at Google at the time, working on the Go implementation of Protocol Buffers. But the repository lives under a personal account and the commits use a personal email address—this did not start as work. The sequence of subsequent commits preserves the order of the design.

2020-10-23  Initial commit of base files
2020-10-29  Add README.md (#1)
2020-11-21  Add initial API for syntactic JSON serialization (#2)
2020-11-23  Add error types and functionality (#7)
2020-11-23  Add "Design overview" section to the readme (#10)
2020-12-03  Add state machine for validating token sequences (#8)
2020-12-13  Add basic serialization functionality (#11)
2021-01-26  Implement Token (#22)
2021-02-05  Implement Encoder (#32)
2021-02-20  Implement Decoder (#33)

The first API added was “syntactic JSON serialization”—the syntax layer. The mapping between Go and JSON, the semantic layer, arrived three weeks later.

The separation of syntax and semantics was not something carved out later for performance; it was there from the very first API commit. Why it was there ties back to how Joe Tsai came to this work.

The constraints of protojson

Joe Tsai worked on the Go implementation of Protocol Buffers. It includes a package called protojson that converts between protobuf messages and JSON.

From the official Go blog post “A new experimental Go API for JSON”:

After previous work on the Go API for Protocol Buffers, Joe Tsai was disappointed that the protojson package needed to use a custom JSON implementation because encoding/json was neither capable of adhering to the stricter JSON standard that the Protocol Buffer specification required, nor of efficiently serializing JSON in a streaming manner.

Strictness and streaming are both matters not of mapping Go to JSON, but of how JSON syntax is read and written. What protojson carried in-house was exactly that syntax layer. That is likely why the first commit started with the syntax layer. And the layering itself presumably exists not for speed, but because an implementation that wanted to follow the JSON standard strictly and to read and write without assembling entire values already existed outside the standard library. The prototype of jsontext was the JSON handling that lived inside protobuf.

The six goals in the README

The first README of go-json-experiment/json listed six goals. Two of them would shape the development that followed.

One is the treatment of compatibility.

Behaviorally, we should aim for 95% to 99% backwards compatibility. We do not aim for 100% compatibility since we want the freedom to break certain behaviors that are now considered to have been a mistake.

Here, for the first time, the things that could not be fixed in v1 are called a “mistake.” The other goal anticipated the final shape.

Since the v1 implementation must stay forever, it would be beneficial if v1 could be implemented under the hood with v2

As of October 2020, the shape that would actually be adopted six years later was already written down (explained later in this book). The README of the time also had an “Expectations” section1 listing five anticipated outcomes, the first of which was abandoning the effort. This was clearly not a document written on the assumption of success.

Proving it at Tailscale

A design and an implementation are not, by themselves, grounds for entering the standard library. This prototype was actually used.

Joe Tsai moved to Tailscale in July 2021, and in October 2022 added the module as a dependency with his own hands. It was not that some outside party evaluated it and adopted it in production; the author started using it at his new employer.

The Stability section of Discussion #63397 contains the following.

We have confidence in the correctness and performance of the module as it has been used internally at Tailscale in various production services. However, the module is an experiment and breaking changes are expected to occur based on feedback in this discussion, it should not be depended upon by publicly available code, otherwise we can run into situations where large programs fail to build.

The production services in question are Tailscale’s non-public code. In its public repositories at that point, the only thing using this module was a single command for formatting logs.

The latter caveat exists to avoid the problems that arise when public libraries depend on an experimental module. If a program P depends on modules A and B, and A and B require different versions of go-json-experiment, the build fails.

The official Go blog gives one more real-world example: loading Kubernetes’ OpenAPI specification, where nested UnmarshalJSON implementations re-parsed repeatedly and processing time grew quadratically. A performance limit rooted in the design, becoming a problem at real-world scale.


  1. It has since been removed. ↩︎