pickuma.
Dev Knowledge

What Go 1.27's encoding/json/v2 means for your code

Go 1.27 makes encoding/json/v2 stable. It changes defaults for duplicate keys, nil slices and field matching. Here is what to check before you switch.

6 min read

Go 1.27 ships two new standard library packages, encoding/json/v2 and encoding/json/jsontext. According to the Go Blog post introducing the new JSON API, both are now stable and generally available. The old encoding/json package stays where it is. What decides whether this matters to you is a short list of changed defaults. Version 2 rejects duplicate object keys, writes nil slices and maps as [] and {}, and matches JSON keys to struct fields case-sensitively. If you change an import and nothing else, JSON that used to work can behave differently.

The background you need

Marshaling and unmarshaling. Marshaling turns a Go value, such as a struct, into JSON bytes. Unmarshaling goes the other way. The Go Blog draws a line that the old package blurred. Encoding and decoding deal only with JSON syntax: braces, commas, strings, and whether the text is valid. Marshaling and unmarshaling deal with meaning: which JSON key fills which struct field.

Struct tags. These are the backtick annotations on struct fields, such as json:"name,omitempty". They tell the package what key to use and when to leave a field out. omitempty is the tag option that drops a field from the output when it is considered empty.

The Go 1 compatibility promise. The Go project guarantees that code written against Go 1 keeps compiling and behaving the same in later releases. That is why your old code still builds. It also means the Go team cannot quietly change how encoding/json behaves, even where it behaves badly. The blog says the problems are inherent in the existing API, which makes them practically impossible to fix within that promise.

Why a /v2 path. In Go, an incompatible new version of a package gets a new import path ending in /v2. Old and new can then live side by side. Think of a government form that gets a revised edition. The old edition stays valid and the new one has new rules. The analogy breaks in one way: you can use both editions in the same program at once, even in the same file under different import names.

Nil versus empty. A Go slice or map can be nil, meaning it was never allocated, or empty, meaning allocated but holding nothing. Both have length zero. Version 1 writes a nil slice as JSON null, not []. Many JSON libraries in other languages refuse to load null into a field they expect to be a list. On the other end, that shows up as a crash or a validation error.

Duplicate keys. A JSON object can list the same key twice, for example a role key set first to user and then to admin. The JSON standard, RFC 8259, does not say which value wins. Two parsers can disagree, and an attacker can exploit that gap. The Go Blog cites CVE-2017-12635 as a real case.

Streaming. A streaming parser handles JSON piece by piece instead of holding the whole document in memory. The old MarshalJSON and UnmarshalJSON methods force whole-value handling. According to the blog, when these methods call Marshal or Unmarshal recursively, performance becomes quadratic. In plain terms, the cost grows much faster than the input size.

Last year the Go team released this API as an experiment. Go 1.27 is the release where it becomes stable.

What actually changed in Go 1.27

New:

  • jsontext is a syntax-only layer. It has a truly streaming Encoder and Decoder and does not use reflection.
  • encoding/json/v2 adds MarshalWrite and UnmarshalRead, which work directly on an io.Writer or io.Reader. Options are now an argument to every marshal and unmarshal function. In v1 you could set options only on the Encoder and Decoder types.
  • The new interfaces MarshalerTo and UnmarshalerFrom let your types read and write through the streaming encoder and decoder. Options pass down the call stack with them.
  • WithMarshalers and WithUnmarshalers let the caller override how any type is serialized, even types you do not own.

Changed defaults in v2, according to the Go Blog:

  • Duplicate names in a JSON object cause an error.
  • Nil slices and maps marshal as [] and {}.
  • Matching JSON names to struct fields is case-sensitive.
  • omitempty now omits a field only if it would encode as null, an empty string, [], or {}.
  • Marshaling a time.Duration reports an error unless you pick a format through an option.

On nil slices, the blog says: “A survey indicated that most Go users prefer that nil slices and maps are marshaled as an empty JSON array or object by default.”

Already true, and still true: encoding/json is unchanged in what you can rely on. The Marshaler and Unmarshaler interfaces are identical in v2. v1’s RawMessage is now a type alias for jsontext.Value, so the two are interchangeable.

Performance: According to the Go Blog, Marshal is roughly at parity with v1, sometimes slightly faster and sometimes slightly slower. Unmarshal is significantly faster, with benchmarks showing improvements of up to 10x. The blog says Kubernetes saw orders-of-magnitude gains in one service after it switched recursive OpenAPI parsing to UnmarshalJSONFrom. The team says to expect more optimization in Go 1.28.

What it means for you

If you do nothing, nothing breaks. v2 is opt-in. The changed defaults matter at the moment you migrate. Each one maps to a concrete failure you can test for.

  • Clients sending odd capitalization. If a client sends userId but your field resolves to UserID, v1 matched it anyway. The blog calls this behavior “a surprising default, a potential security vulnerability, and a performance limitation.” Under v2 that field silently stays at its zero value. Check your request logs before you assume every client uses exact casing.
  • Consumers that check for null. Front-end code or downstream services may test for null where they now get []. Usually that is an improvement, but golden-file tests and snapshot tests will flag it.
  • omitempty fields. A bool set to false or an int set to 0 encodes as false or 0. Neither is on the v2 list of empty values, so v2 keeps those fields where v1 dropped them. Payload shapes can change.
  • Duration fields. Any struct with a time.Duration needs an explicit option, or marshaling fails.
  • Duplicate keys. Input that v1 accepted now errors. This is the safer default, but it can surface sloppy upstream producers.

The blog says most behavior changes have a struct tag option or a caller-specified option to keep v1 semantics. You can migrate incrementally: switch the import, restore old behavior where a test fails, then remove those overrides one at a time.

If you have custom UnmarshalJSON methods on hot paths, adding UnmarshalJSONFrom is where the blog points for the largest gains.

What to watch next: the Migrating to v2 guide the blog points to, and the further performance work the Go team says is coming in Go 1.28.

What we don’t know yet

  • The future of v1. The post does not say whether encoding/json will be deprecated or how long it will be maintained as-is.
  • Library adoption. Web frameworks and SDKs that marshal on your behalf choose their own timeline. The post does not say how quickly they will move or how mixed v1 and v2 code paths will interact in practice.
  • Invalid UTF-8. The blog says the default should report an error for invalid UTF-8. It does not include this in its most-notable list of v2 behavior changes. Check the package documentation for the exact default.
  • Your workload’s speedup. Up to 10x is a ceiling from benchmarks, not a promise for your payloads.
  • Other options. The blog says several v2 options are not covered in the post.

FAQ

Do I have to change anything when I upgrade to Go 1.27?
No. The old encoding/json package keeps its behavior under the Go 1 compatibility promise. The new packages are opt-in.
Can I use v1 and v2 in the same program?
Yes. They have different import paths, so you can migrate one package at a time. The RawMessage type in v1 is now an alias for jsontext.Value, so raw JSON values pass between them directly.
Will v2 make my service faster?
Possibly for decoding. According to the Go Blog, Unmarshal benchmarks improved by up to 10x and Marshal is roughly at parity. The biggest gains come from implementing the new streaming UnmarshalJSONFrom and MarshalJSONTo methods.

Related tools

Some links above are affiliate links. We may earn a commission if you sign up. See our disclosure for details.

Related reading

See all Dev Knowledge articles →

Get the best tools, weekly

One email every Friday. No spam, unsubscribe anytime.