Go JSON: Migrating from v1 to v2

Go 1.25 introduces a second version of the json package with new capabilities, API fixes, and performance improvements, but also many breaking changes; this article walks through the core differences, migration pitfalls, new streaming interfaces, option controls, tag extensions, custom codecs, default behavior shifts, and performance considerations.

FunTester
FunTester
FunTester
Go JSON: Migrating from v1 to v2

Introduction

Go 1.25 adds a second version of the json package ( encoding/json/v2). The release is not a minor patch: it adds new capabilities, fixes API and behavioral defects, improves performance, and introduces numerous incompatible changes.

Basic usage remains the same

The most common Marshal and Unmarshal patterns work unchanged in both versions: encode a struct to JSON bytes and decode the bytes back to a struct.

type Person struct {
    Name string
    Age  int
}

alice := Person{Name: "Alice", Age: 25}

b, err := json.Marshal(alice)
fmt.Println(string(b), err)

err = json.Unmarshal(b, &alice)
fmt.Println(alice, err)

Direct read/write

In v1, encoding to an io.Writer or decoding from an io.Reader required an Encoder or Decoder. v2 adds MarshalWrite and UnmarshalRead, which operate directly on the writer/reader without an intermediate object. The new functions do not automatically append a newline like Encoder.Encode, and UnmarshalRead reads until io.EOF, affecting stream‑processing semantics.

alice := Person{Name: "Alice", Age: 25}

out := new(strings.Builder)
json.MarshalWrite(out, alice)
fmt.Println(out.String())

in := strings.NewReader(`{"Name":"Bob","Age":30}`)
var bob Person
json.UnmarshalRead(in, &bob)
fmt.Println(bob)

Using jsontext for streaming

v2 moves Encoder and Decoder to a new jsontext package and renames the core functions. The mapping is:

v1 Encoder.Encode → v2 json.MarshalEncode + jsontext.Encoder v1 Decoder.Decode → v2 json.UnmarshalDecode + jsontext.Decoder Streaming multiple JSON values can be done as follows:

people := []Person{{Name: "Alice", Age: 25}, {Name: "Bob", Age: 30}, {Name: "Cindy", Age: 15}}

out := new(strings.Builder)
enc := jsontext.NewEncoder(out)
for _, p := range people {
    json.MarshalEncode(enc, p)
}
fmt.Print(out.String())

in := strings.NewReader(`
 {"Name":"Alice","Age":25}
 {"Name":"Bob","Age":30}
 {"Name":"Cindy","Age":15}
`)

dec := jsontext.NewDecoder(in)
for {
    var p Person
    err := json.UnmarshalDecode(dec, &p)
    if err == io.EOF { break }
    fmt.Println(p)
}
UnmarshalDecode

is the true per‑value streaming decoder; it returns io.EOF after each JSON value, unlike the older Decoder.Decode which reads only the next value.

Option‑based encoding control

v2 consolidates many optional behaviours into functional options. Common options include: FormatNilMapAsNull / FormatNilSliceAsNull: control how nil maps and slices are encoded. MatchCaseInsensitiveNames: make field‑name matching case‑insensitive. Multiline: expand JSON objects across multiple lines. OmitZeroStructFields: omit zero‑value struct fields. SpaceAfterColon / SpaceAfterComma: add spaces after ':' and ','. StringifyNumbers: encode numeric types as strings. WithIndent / WithIndentPrefix: control indentation of nested structures.

The legacy MarshalIndent function has been removed; indentation is now achieved via the WithIndent option.

alice := Person{Name: "Alice", Age: 25}

b, _ := json.Marshal(alice,
    json.OmitZeroStructFields(true),
    json.StringifyNumbers(true),
    jsontext.WithIndent("  "))
fmt.Println(string(b))

Multiple options can be combined with JoinOptions:

opts := json.JoinOptions(
    jsontext.SpaceAfterColon(true),
    jsontext.SpaceAfterComma(true),
)
b, _ := json.Marshal(alice, opts)
fmt.Println(string(b))

Tag extensions

All v1 tags ( omitzero, omitempty, string, -) continue to work. New tags add extra capabilities: case:ignore / case:strict: control case handling for field names. format:template: format field values with a template. inline: promote nested fields to the parent object. unknown: collect unknown fields into a map for forward‑compatible protocols.

Example of inline and format:DateOnly:

type Person struct {
    Name      string    `json:"name"`
    BirthDate time.Time `json:"birth_date,format:DateOnly"`
    Address   `json:",inline"`
}

type Address struct {
    Street string `json:"street"`
    City   string `json:"city"`
}

Example of unknown handling:

type Person struct {
    Name string `json:"name"`
    Data map[string]any `json:",unknown"`
}

// JSON with extra fields "hobby" and "friends" will be stored in Data.

Custom codecs

The classic Marshaler / Unmarshaler interfaces remain usable. The standard library now recommends the streaming variants MarshalerTo and UnmarshalerFrom, which work directly with jsontext.Encoder and jsontext.Decoder:

type Success bool

func (s Success) MarshalJSON() ([]byte, error) {
    if s { return []byte(`"✓"`), nil }
    return []byte(`"✗"`), nil
}

func (s *Success) UnmarshalJSON(data []byte) error {
    *s = string(data) == `"✓"`
    return nil
}

For stream‑oriented handling:

func (s Success) MarshalJSONTo(enc *jsontext.Encoder) error {
    if s { return enc.WriteToken(jsontext.String("✓")) }
    return enc.WriteToken(jsontext.String("✗"))
}

func (s *Success) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
    tok, err := dec.ReadToken()
    *s = tok.String() == `✓`
    return err
}

Generic helpers MarshalFunc and UnmarshalFunc allow composable custom handlers, which can be combined with JoinMarshalers / JoinUnmarshalers. Returning json.SkipFunc skips a handler and falls back to the default logic.

boolMarshaler := json.MarshalFunc(func(val bool) ([]byte, error) {
    if val { return []byte(`"✓"`), nil }
    return []byte(`"✗"`), nil
})

data, err := json.Marshal(true, json.WithMarshalers(boolMarshaler))
fmt.Println(string(data), err)

Default‑behavior changes

Encoding differences worth testing:

nil slice: v1 → null, v2 → [] (use FormatNilSliceAsNull to keep old behavior).

nil map: v1 → null, v2 → {} (use FormatNilMapAsNull to keep old behavior).

byte slice: v1 → numeric array, v2 → Base64 string (adjust with format:array or format:base64 tags).

Invalid UTF‑8 in strings: allowed in v1, rejected in v2 (use AllowInvalidUTF8 to retain compatibility).

Decoding differences:

Field‑name case sensitivity: v1 ignored case, v2 matches exactly (use json.MatchCaseInsensitiveNames(true) or the case tag to revert).

Duplicate fields: v1 allowed, v2 rejects (use AllowDuplicateNames to permit).

Performance and migration boundaries

Encoding speed is roughly comparable between versions; decoding is 2.7–10.2× faster in v2. Switching regular MarshalJSON / UnmarshalJSON to the streaming MarshalJSONTo / UnmarshalJSONFrom can yield additional gains, sometimes turning O(n²) algorithms into O(n) (e.g., a Kubernetes OpenAPI case saw ~40× improvement).

Performance numbers do not replace migration testing. For existing services, regression tests should cover nil container representation, byte‑array format, field‑name matching rules, duplicate‑field handling, and the semantics of reading one value versus the whole input.

Pre‑migration notes

As of Go 1.25, json/v2 is experimental and must be enabled at build time with GOEXPERIMENT=jsonv2. The flag also switches the standard json package to the new implementation, providing speed benefits and partial compatibility options.

When migrating, start with the clearest boundaries: separate full‑document reads from streaming reads, add compatibility options for external protocols, and run regression tests against real payloads. This approach leverages the new capabilities while avoiding hidden protocol breakages.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

MigrationperformanceGojsonoptionsjsonv2custom codec
FunTester
Written by

FunTester

10k followers, 1k articles | completely useless

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.