Skip to content

Web Development · Go

Go encoding/json v2 Migration: What Changes in Your Code

Moving from encoding/json to json/v2 in Go 1.27: the strict defaults that change behavior, the options that restore v1, and a safe rollout order.

Prathviraj Singh

Prathviraj Singh

5 min read

A brass mesh sieve dropping clear glass beads into a shallow brass tray while a few colored beads stay caught in the mesh

Sponsored

Share

If you are moving a Go service from encoding/json to encoding/json/v2, the work is smaller than it looks and the risk is in five default behaviors. v2 is stricter, so payloads that v1 quietly accepted can now fail or decode differently. Each difference has an option that restores the old behavior, which means you can migrate one call site at a time.

This is a companion to our Go 1.27 upgrade guide. The facts here come from the v2 package documentation and the Go 1.27 release notes.

First, check whether you need to migrate at all

Go 1.27 runs encoding/json on the v2 implementation internally. The v1 API stays, v1 behavior is configured through options, marshaling is at parity, and unmarshaling is significantly faster according to the release notes. So a plain toolchain upgrade already gives you most of the performance story.

Migrate to the v2 package when you want the new API: options passed per call, omitzero, streaming through jsontext, and custom marshalers registered per type rather than per method. If none of that appeals, upgrade the toolchain and stop.

The API in one screen

The v2 package keeps the familiar names and adds variadic options:

import "encoding/json/v2"

// Encode
b, err := json.Marshal(order)
err = json.MarshalWrite(w, order)

// Decode
var o Order
err = json.Unmarshal(b, &o)
err = json.UnmarshalRead(r, &o)

// With options
err = json.Unmarshal(b, &o, json.RejectUnknownMembers(true))

The package name is still json, so existing call sites often change only in the import path. The lower-level Encoder and Decoder types moved to encoding/json/jsontext, which works on sequences of tokens and values.

The five differences that change behavior

1. Field names match case-sensitively

v1 matched JSON member names to struct fields ignoring case. v2 does not. A payload with "userID" no longer fills a field tagged json:"userId", and nothing errors. The field just stays zero.

type Event struct {
	UserID string `json:"userId"`
}

// {"userID":"u_42"} decodes UserID as "" in v2

Fix the tag if your own clients send the odd casing. If you cannot control the callers, either add case:ignore to the tag (json:"userId,case:ignore") or pass json.MatchCaseInsensitiveNames(true) for that call.

2. Duplicate object member names are rejected

v1 allowed {"a":1,"a":2} and kept the last value. v2 returns an error. This is a security improvement, since parsers that disagree about duplicates are a classic smuggling vector, but it will break a client that emits duplicates by accident. The jsontext.AllowDuplicateNames option turns the old tolerance back on.

3. Invalid UTF-8 is rejected

v1 replaced invalid byte sequences with the Unicode replacement character. v2 returns an error. Data that passed through a lossy upstream (a database column with mixed encodings, a legacy ingest job) is where this shows up. jsontext.AllowInvalidUTF8 restores the tolerance, but cleaning the source is the better fix.

4. Nil slices and maps encode as empty, not null

v2 writes a nil slice as [] and a nil map as {}. A front end that checks === null to mean “not loaded yet” will misbehave. Use json.FormatNilSliceAsNull(true) and json.FormatNilMapAsNull(true) to keep v1’s output.

5. omitzero joins omitempty, and the two mean different things

omitempty in v2 is defined by the JSON encoding: omit when the value encodes as null, an empty string, an empty object or an empty array. omitzero is defined by the Go type: omit when the field holds its zero value, or when its IsZero method reports true. A time.Time field is the textbook case. Its zero value encodes as a non-empty string, so omitempty keeps it, while omitzero drops it.

type Invoice struct {
	PaidAt time.Time `json:"paidAt,omitzero"`
	Notes  []string  `json:"notes,omitempty"`
}

If you relied on v1’s omitempty semantics for structs or other types, review each tag rather than search-and-replace.

Unknown fields and numbers

Both versions ignore unknown members by default. If you want strict decoding for an internal API, v2 gives you json.RejectUnknownMembers(true) as an option instead of a decoder method, which is easier to apply uniformly. Other options worth knowing: json.Deterministic(true) for stable output (useful for hashing and golden files), json.StringifyNumbers(true), and WithMarshalers and WithUnmarshalers for per-call custom handling.

Struct tag options also shifted during the experiment phase. Per the release notes, inline was renamed to embed, and the format and unknown tag options and the SkipFunc sentinel were removed. If you tried an earlier GOEXPERIMENT build of v2, re-read your tags.

A rollout order that doesn’t wake anyone up

  1. Capture real payloads. Sample a day of request bodies from the endpoints you will migrate, with secrets stripped. Synthetic fixtures don’t contain the casing quirks and stray bytes that real clients send.
  2. Replay through both. Decode each payload with v1 and v2 in a test, and compare the resulting structs. Any difference is one of the five items above.
  3. Pin v1 behavior where needed. Add the specific restoring option at the call site, with a comment naming the client that needs it. Don’t enable every lenient option globally.
  4. Migrate response encoding last. Decoding bugs reject requests loudly. Encoding changes (nil as empty, omitted fields) change what consumers see, so check front-end and mobile handling first.
  5. Keep an escape hatch for one release. GOEXPERIMENT=nojsonv2 swaps the old implementation back in, though the notes say it is expected to be removed later, so don’t build a plan around it.

If your payloads come from LLM tool calls, structured outputs and JSON tool calls covers why strict parsing matters there. And if you are growing a Go team to own this kind of migration, here is what to screen for when hiring a Go developer.

The takeaway: audit case sensitivity and nil-versus-empty first. Those two fail silently, and each is fixed with one option or one tag.

Frequently asked questions

Do I need to import encoding/json/v2 to get the speedup?
Not necessarily. The Go 1.27 release notes say encoding/json is now backed by the v2 implementation, with marshaling at parity and unmarshaling significantly faster. Importing the v2 package directly gives you the new API and stricter defaults.
Why did my JSON fields stop populating after switching to v2?
Most likely case sensitivity. v1 matched object member names to struct fields case-insensitively. v2 is strict by default. Fix the tag to match what clients send, or add the case:ignore tag option on that field, or use the MatchCaseInsensitiveNames option for the whole call.
What is the difference between omitempty and omitzero in v2?
omitzero omits a field when it holds the Go zero value, or when its IsZero method returns true. omitempty omits a field when its JSON encoding would be null, an empty string, an empty object or an empty array. A zero time.Time is the classic case that omitzero handles and omitempty does not.
Can I still encode nil slices as null?
Yes. v2 encodes nil slices as empty arrays and nil maps as empty objects by default. Pass the FormatNilSliceAsNull and FormatNilMapAsNull options to get v1's null output, which matters if a front end distinguishes the two.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored