encoding/json/v2

Updated

September 13, 2026

encoding/json/v2

The boring default for new JSON code on Go 1.27+ is encoding/json/v2. The import path changed; the top-level function names did not. You still call json.Marshal and json.Unmarshal. What changed is behavior: unknown fields are rejected, zero-value omission is more precise, and streaming no longer needs an intermediate []byte buffer. Leave your existing encoding/json code alone unless you have a concrete reason to migrate.

Mental model

v2 separates options from the top-level functions. json.Marshal(v) works with defaults. When you need to tweak behavior, pass a json.MarshalOptions or json.UnmarshalOptions struct — or use the streaming helpers directly.

Three additions matter most:

  • omitzero — omits a field when it is the zero value. Works on structs, numerics, bools, and time.Time. v1’s omitempty misses zero-value structs because an empty struct is not “empty” to v1.
  • MarshalWrite(w io.Writer, v any) — encodes directly to any writer. No intermediate []byte.
  • UnmarshalRead(r io.Reader, v any) — decodes from any reader. No intermediate []byte.

Unknown JSON keys are rejected by default. To allow extras, pass json.UnmarshalOptions{RejectUnknownMembers: false}.

encoding/json and encoding/json/v2 are two distinct packages. Both can be compiled into the same binary, but you must not mix their types in a single call.

Worked examples

Case 1: Basic marshal and unmarshal with omitzero

A Ticket struct with a string note and an integer priority. Watch what omitzero and omitempty each produce when fields are at their zero values.

Save as ticket_v2.go.

// ticket_v2.go
package main

import (
    "encoding/json/v2"
    "fmt"
)

// Ticket represents a desk work item.
// Note uses omitempty  — zero string ("") is empty, so it is omitted.
// Priority uses omitzero — zero int (0) is the zero value, so it is omitted.
// Closed uses omitzero  — false is the zero value for bool.
type Ticket struct {
    ID       int    `json:"id"`
    Note     string `json:"note,omitempty"`
    Priority int    `json:"priority,omitzero"`
    Closed   bool   `json:"closed,omitzero"`
}

func main() {
    // All fields set.
    full := Ticket{ID: 1, Note: "no onions", Priority: 2, Closed: false}
    a, err := json.Marshal(full)
    if err != nil {
        fmt.Println("marshal:", err)
        return
    }
    fmt.Println(string(a))

    // Zero-value fields.
    empty := Ticket{ID: 2}
    b, err := json.Marshal(empty)
    if err != nil {
        fmt.Println("marshal:", err)
        return
    }
    fmt.Println(string(b))

    // Round-trip.
    var got Ticket
    if err := json.Unmarshal(a, &got); err != nil {
        fmt.Println("unmarshal:", err)
        return
    }
    fmt.Printf("id=%d note=%q priority=%d closed=%v\n",
        got.ID, got.Note, got.Priority, got.Closed)
}

Run:

go run ticket_v2.go

Output:

{"id":1,"note":"no onions","priority":2}
{"id":2}
id=1 note="no onions" priority=2 closed=false

Closed is absent on full even though it was explicitly falseomitzero matched the bool zero value. Priority on empty also disappeared. ID has no omit option, so it always appears. Use omitzero for numerics, bools, and structs. Use omitempty for strings, slices, and maps where “non-empty” is the meaningful distinction.

Case 2: Streaming with MarshalWrite and UnmarshalRead

Writing JSON to a file and reading it back without building a []byte in memory first. The desk shift roster is serialised directly to a *os.File.

Save as shift_stream.go.

// shift_stream.go
package main

import (
    "encoding/json/v2"
    "fmt"
    "os"
)

// Shift is one desk shift record.
type Shift struct {
    StaffID int    `json:"staff_id"`
    Start   string `json:"start"`
    End     string `json:"end"`
    Notes   string `json:"notes,omitempty"`
}

func main() {
    roster := []Shift{
        {StaffID: 101, Start: "08:00", End: "16:00"},
        {StaffID: 102, Start: "12:00", End: "20:00", Notes: "training day"},
        {StaffID: 103, Start: "16:00", End: "00:00"},
    }

    // Write to a temp file — no []byte buffer needed.
    f, err := os.CreateTemp("", "roster-*.json")
    if err != nil {
        fmt.Println("create:", err)
        return
    }
    defer os.Remove(f.Name())

    if err := json.MarshalWrite(f, roster); err != nil {
        fmt.Println("write:", err)
        f.Close()
        return
    }
    f.Close()

    // Read back without buffering into memory.
    r, err := os.Open(f.Name())
    if err != nil {
        fmt.Println("open:", err)
        return
    }
    defer r.Close()

    var got []Shift
    if err := json.UnmarshalRead(r, &got); err != nil {
        fmt.Println("read:", err)
        return
    }

    for _, s := range got {
        fmt.Printf("staff=%d %s\u2013%s", s.StaffID, s.Start, s.End)
        if s.Notes != "" {
            fmt.Printf(" (%s)", s.Notes)
        }
        fmt.Println()
    }
}

Run:

go run shift_stream.go

Output:

staff=101 08:00–16:00
staff=102 12:00–20:00 (training day)
staff=103 16:00–00:00

MarshalWrite and UnmarshalRead accept any io.Writer / io.Reader. Pass an HTTP response body, a compressed writer, or a network connection. The JSON is written in one pass with no intermediate allocation.

Case 3: Unknown fields are rejected by default

v2’s strict default means a JSON payload with extra keys fails immediately. This catches misspelled field names and API version mismatches at the boundary instead of silently discarding data.

Save as unknown_fields.go.

// unknown_fields.go
package main

import (
    "encoding/json/v2"
    "fmt"
)

// Order is a desk purchase record.
type Order struct {
    ID    int `json:"id"`
    Table int `json:"table"`
}

func main() {
    // JSON contains an extra key "discount" that Order does not have.
    payload := `{"id":7,"table":3,"discount":10}`

    // Default: unknown members are rejected.
    var strict Order
    if err := json.Unmarshal([]byte(payload), &strict); err != nil {
        fmt.Println("strict error:", err)
    }

    // Relaxed: allow unknown members.
    var relaxed Order
    opts := json.UnmarshalOptions{RejectUnknownMembers: false}
    if err := opts.Unmarshal([]byte(payload), &relaxed); err != nil {
        fmt.Println("relaxed error:", err)
        return
    }
    fmt.Printf("relaxed: id=%d table=%d\n", relaxed.ID, relaxed.Table)
}

Run:

go run unknown_fields.go

Output:

strict error: json: unknown name "discount"
relaxed: id=7 table=3

The error message names the unknown key. v1 silently ignored "discount" in both cases. Use the strict default when you own the schema. Set RejectUnknownMembers: false only when consuming external APIs that may add fields over time.

Case 4: v1 vs v2 tag comparison

The struct tags look nearly identical. The meaningful difference is omitzero versus omitempty on embedded structs. Here both packages marshal the same data so you can compare side by side.

Save as tag_compare.go.

// tag_compare.go
package main

import (
    jsonv1 "encoding/json"
    jsonv2 "encoding/json/v2"
    "fmt"
)

// PriceV1 uses v1 omitempty tags.
// omitempty on a zero struct does NOT omit it — v1 checks for empty, not zero.
type PriceV1 struct {
    ItemID   int     `json:"item_id"`
    Amount   float64 `json:"amount,omitempty"`
    Discount float64 `json:"discount,omitempty"`
}

// PriceV2 uses v2 omitzero tags.
// omitzero on a zero struct DOES omit it.
type PriceV2 struct {
    ItemID   int     `json:"item_id"`
    Amount   float64 `json:"amount,omitzero"`
    Discount float64 `json:"discount,omitzero"`
}

// Meta is an embedded struct to expose the omitempty vs omitzero difference.
type Meta struct {
    Author string
    Rev    int
}

// DocV1 uses v1: a zero Meta struct is NOT omitted by omitempty.
type DocV1 struct {
    ID   int  `json:"id"`
    Meta Meta `json:"meta,omitempty"`
}

// DocV2 uses v2: a zero Meta struct IS omitted by omitzero.
type DocV2 struct {
    ID   int  `json:"id"`
    Meta Meta `json:"meta,omitzero"`
}

func main() {
    p1, _ := jsonv1.Marshal(PriceV1{ItemID: 42, Amount: 9.99})
    p2, _ := jsonv2.Marshal(PriceV2{ItemID: 42, Amount: 9.99})
    fmt.Println("v1 price:", string(p1))
    fmt.Println("v2 price:", string(p2))

    // Zero embedded struct.
    d1, _ := jsonv1.Marshal(DocV1{ID: 1})
    d2, _ := jsonv2.Marshal(DocV2{ID: 1})
    fmt.Println("v1 zero meta:", string(d1))
    fmt.Println("v2 zero meta:", string(d2))
}

Run:

go run tag_compare.go

Output:

v1 price: {"item_id":42,"amount":9.99}
v2 price: {"item_id":42,"amount":9.99}
v1 zero meta: {"id":1,"meta":{"Author":"","Rev":0}}
v2 zero meta: {"id":1}

For primitive fields, omitempty and omitzero agree. For structs they diverge. v1 includes the zero struct because a struct is never considered “empty”. v2 drops it because every field is the zero value. This single difference is the most common motivation to use omitzero in new code.

The trap

encoding/json and encoding/json/v2 are separate packages with separate named types. json.RawMessage, json.Value, and the json.Marshaler interface each exist independently in both packages. Passing a v1 type where a v2 type is expected causes a compile error.

Save as cross_import.go.

// cross_import.go
package main

import (
    jsonv1 "encoding/json"
    jsonv2 "encoding/json/v2"
    "fmt"
)

func main() {
    // v1 round-trip.
    raw1, err := jsonv1.Marshal(map[string]int{"table": 3})
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("v1:", string(raw1))

    // v2 round-trip.
    raw2, err := jsonv2.Marshal(map[string]int{"table": 3})
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("v2:", string(raw2))

    // The bug looks like this (compile error — do not do this):
    //
    //   var rm jsonv1.RawMessage = raw1
    //   var v2val jsonv2.Value
    //   v2val, _ = jsonv2.Marshal(rm)   // jsonv1.RawMessage ≠ jsonv2.RawMessage
    //
    // The fix: pick one package per file.
    // If you must use both, keep v1 code in one file and v2 code in another.
    // Alias only for migration comparisons like this example.
}

Run:

go run cross_import.go

Output:

v1: {"table":3}
v2: {"table":3}

The plain []byte produced by Marshal is compatible across packages — it is just bytes. The incompatibility arises when you use package-specific named types like RawMessage or Value across the package boundary. The fix is to use one package per file.

The boring rule

  • New code on Go 1.27+: import encoding/json/v2. Leave existing encoding/json imports unchanged.
  • Use omitzero for numeric, bool, struct, and time.Time fields. Use omitempty for string, slice, and map fields.
  • Use MarshalWrite / UnmarshalRead when you already have a writer or reader. Skip the []byte round-trip.
  • Rely on the strict unknown-field default. Only set RejectUnknownMembers: false when consuming an external API you do not control.
  • Do not pass v1-specific types (json.RawMessage, json.Value, json.Marshaler) to v2 functions, or vice versa.
  • Check the error from Unmarshal. A zero struct on success is silent — validate required fields afterward if zero would be wrong.

Try this

  1. In ticket_v2.go, add a CreatedAt time.Time field tagged `json:"created_at,omitzero"`. Marshal a Ticket with a zero CreatedAt and confirm the field is absent. Then set CreatedAt to time.Now() and confirm it appears.
  2. In shift_stream.go, replace os.CreateTemp with a strings.Builder and pass &b to MarshalWrite. Print b.String() to verify the same JSON is produced without touching the file system.
  3. In unknown_fields.go, add a second unknown key ("region":"west") to the payload. Confirm the error names the first unknown key encountered. Swap the key order in the JSON string and check whether the error message changes.
  4. In tag_compare.go, add a Qty int field to both structs tagged with omitempty and omitzero respectively. Set Qty: 0 and confirm both packages drop the field. Set Qty: -1 and confirm both packages include it.