encoding/json

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. JSON is the default wire format of web services, configuration files, command-line tools that talk to other programs and log pipelines, and Go has shipped a JSON package since before Go 1.0. Go 1.27 is the release where that package changes underneath you: the new encoding/json/v2 and encoding/json/jsontext packages are generally available (no GOEXPERIMENT needed), and the classic encoding/json ("v1") API is now implemented on top of v2 with a set of options that preserve its historical behaviour. This page covers all three packages: the v1 API that almost every existing program uses, the v2 API that new code should prefer, the exact differences, how to migrate, and the other encodings in the standard library.

It builds on Structs and Embedding (tags, promoted fields), Interfaces (Marshaler, encoding.TextMarshaler, any) and Methods and Receivers (pointer receivers and addressability). Related pages: Error Handling (errors.As on the JSON error types), I/O, Files and the Filesystem (the io.Reader and io.Writer chain a decoder reads from), net/http Clients and Servers (the shelf JSON API), Structured Logging with slog (slog.JSONHandler), Time and Timers (time.Time and time.Duration on the wire), Strings, Runes and UTF-8, Arrays and Slices (nil versus empty), Maps, Reflection (how the package reads tags) and Testing. For the same job in other languages see Rust (serde), Swift (Codable) and JavaScript (JSON); for JSON inside the database see SQL JSON and XML functions.

Every example was compiled, vetted with go vet and run with Go 1.27.2; the output shown is the real output (compiler, vet and error messages are likewise copied from real runs). The go.mod of the example module is:

module example.com/shelf

go 1.27

Three packages, one default

Go 1.27 contains three packages that deal with JSON. They are layered, and understanding the layering explains most of the rest of this page.

Package Level What it is for

encoding/json

"v1": the classic API (Marshal, Unmarshal, Encoder, Decoder, RawMessage, Number)

Existing code, and any code that needs the historical behaviour. It is covered by the Go 1 compatibility promise and will not go away. Since Go 1.27 it is implemented by calling v2 with json.DefaultOptionsV1().

encoding/json/v2

Semantic layer: Go values to and from JSON

New code. Same shape as v1 (Marshal, Unmarshal) plus MarshalWrite, UnmarshalRead, MarshalEncode, UnmarshalDecode, variadic options, per-call marshalers and stricter defaults.

encoding/json/jsontext

Syntactic layer: tokens and values

Reading and writing JSON text without Go types: Decoder, Encoder, Token, Value, Pointer, formatting and canonicalization. v2 is built on it, and v2 marshalers use it.

flowchart TB app["your code"] v1["encoding/json (v1 API)"] v2["encoding/json/v2 (Marshal, Unmarshal, options)"] text["encoding/json/jsontext (Decoder, Encoder, Token, Value)"] app --> v1 app --> v2 app --> text v1 -->|"calls v2 with DefaultOptionsV1()"| v2 v2 -->|"reads and writes tokens"| text

The version history matters because tutorials from different years disagree:

Release JSON change

Go 1.0 to 1.23

Only the v1 API. omitempty is the only way to leave a field out.

Go 1.24

The omitzero struct tag option (uses IsZero() bool if present).

Go 1.25

GOEXPERIMENT=jsonv2 enabled an experimental encoding/json/v2 and encoding/json/jsontext, and when enabled encoding/json ran on the new engine. The v2 API was still changing.

Go 1.26

No JSON package changes; the new new(expr) form (see optional fields) is convenient with pointer fields.

Go 1.27

encoding/json/v2 and encoding/json/jsontext are generally available. encoding/json is backed by v2. GOEXPERIMENT=nojsonv2 restores the original v1 implementation; the release notes say this opt-out is expected to be removed in a future release.

Verifying what the toolchain does

Rather than trust a summary, ask the toolchain. In the Go 1.27.2 source tree the jsonv2 experiment is part of the baseline experiment set (internal/buildcfg), which is why encoding/json/v2 is importable with no flags, and the files of encoding/json are selected by the goexperiment.jsonv2 build tag.

$ go env GOEXPERIMENT

$ GOEXPERIMENT=nojsonv2 go env GOEXPERIMENT
nojsonv2
$ GOEXPERIMENT=jsonv2 go env GOEXPERIMENT
jsonv2
$ go list -f '{{.GoFiles}}' encoding/json
[v2_decode.go v2_encode.go v2_indent.go v2_inject.go v2_options.go v2_scanner.go v2_stream.go]
$ GOEXPERIMENT=nojsonv2 go list -f '{{.GoFiles}}' encoding/json
[decode.go encode.go fold.go indent.go scanner.go stream.go tables.go tags.go]

The default build has an empty GOEXPERIMENT and compiles the v2_*.go files of encoding/json, so v1 is the v2 engine with legacy options. With nojsonv2 the original decode.go/encode.go implementation is compiled and the encoding/json/v2 and jsontext packages are excluded. A program can ask which engine it got, because goexperiment.jsonv2 is an ordinary build constraint:

package main

import (
	"encoding/json"
	"fmt"
)

type Book struct {
	Title string `json:"title"`
	Pages int    `json:"pages"`
}

func main() {
	fmt.Println("implementation:", implementation)

	inputs := []string{
		`{"title": "A", "pages": }`,
		`{"title":"A","pages":"many"}`,
		`{"title":"A"`,
		`{"title":"A\x"}`,
		`[1,2]`,
	}
	for _, in := range inputs {
		var b Book
		fmt.Printf("%-30s %v\n", in, json.Unmarshal([]byte(in), &b))
	}

	_, err := json.Marshal(map[bool]int{true: 1})
	fmt.Println("marshal:", err)
}

The two small files pick the constant with build constraints:

//go:build goexperiment.jsonv2

package main

// implementation reports which engine backs encoding/json in this build.
const implementation = "v2 engine (default in Go 1.27)"
//go:build !goexperiment.jsonv2

package main

const implementation = "original v1 engine (GOEXPERIMENT=nojsonv2)"

Run with the default toolchain and with the opt-out:

implementation: v2 engine (default in Go 1.27)
{"title": "A", "pages": }      invalid character '}' looking for beginning of value
{"title":"A","pages":"many"}   json: cannot unmarshal string into Go struct field Book.pages of type int
{"title":"A"                   unexpected end of JSON input
{"title":"A\x"}                invalid escape sequence `\x` in string
[1,2]                          json: cannot unmarshal array into Go value of type main.Book
marshal: json: unsupported value: jsontext: object member name must be a string after offset 2
$ GOEXPERIMENT=nojsonv2 go run ./e12
implementation: original v1 engine (GOEXPERIMENT=nojsonv2)
{"title": "A", "pages": }      invalid character '}' looking for beginning of value
{"title":"A","pages":"many"}   json: cannot unmarshal string into Go struct field Book.pages of type int
{"title":"A"                   unexpected end of JSON input
{"title":"A\x"}                invalid character 'x' in string escape code
[1,2]                          json: cannot unmarshal array into Go value of type main.Book
marshal: json: unsupported type: map[bool]int

The API behaves the same, but the text of some errors differs (as the Go 1.27 release notes warn). Importing v2 when the experiment is off is a build error:

package main

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

func main() {
	b, err := json.Marshal(map[string]int{"a": 1})
	fmt.Println(string(b), err)
}
$ GOEXPERIMENT=nojsonv2 go build ./e13
package example.com/shelf/e13
	imports encoding/json/v2: build constraints exclude all Go files in /Users/albertoirurueta/sdk/go1.27.2/src/encoding/json/v2

A binary built with a non-default experiment records it in its build information (go version -m binary shows a build GOEXPERIMENT=nojsonv2 line; the default build shows none). Treat nojsonv2 as a temporary bug-compatibility escape hatch, not as a configuration: it is expected to disappear, and code that imports encoding/json/v2 cannot be built with it.

Which one should you use?

  • New code in Go 1.27 or later: use encoding/json/v2 (and jsontext where you need tokens). Its defaults are the stricter, more interoperable ones, it has the better streaming and customization API, and the project’s own guidance is that all new usages should use v2. The module’s go directive must be at least go 1.27.

  • Existing code: keep encoding/json. Nothing breaks; it is covered by the Go 1 compatibility promise and "will forever remain supported". Migrate when there is a reason: stricter parsing, MarshalWrite/UnmarshalRead, per-call marshalers, or the performance of unmarshaling.

  • Libraries that must support Go 1.26: stay on v1. A module that imports encoding/json/v2 needs a Go 1.27 toolchain, and the experiment-era v2 API of Go 1.25 and 1.26 is not the same as the final one (see what changed between the experiment and 1.27).

  • You only need to validate, reformat or scan JSON text: jsontext, or v1’s Valid, Indent and Compact.

The two APIs interoperate. A type that implements encoding/json/v2.MarshalerTo is called by json.Marshal too, and the struct tag options added with v2 (case:ignore, case:strict, embed) are understood by v1 in the default Go 1.27 build (shown in Migrating). The official encoding/json/v2 Migration Guide is the reference for the process.

Release notes also state that unmarshaling is "significantly faster" in the new implementation and marshaling is "broadly at parity" with the old one; measure your own payloads (see Benchmarks, Fuzzing and synctest) before migrating for speed.

encoding/json v1

Marshal and Unmarshal

json.Marshal(v any) ([]byte, error) encodes a Go value as JSON; json.MarshalIndent(v, prefix, indent) does the same with indentation; json.Unmarshal(data []byte, v any) error decodes JSON into the value v points to, which must be a non-nil pointer. A struct field is written only if it is exported, and its JSON name is the json tag name or, with none, the Go field name.

package main

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

type Author struct {
	Name string `json:"name"`
}

type Book struct {
	ISBN    string   `json:"isbn"`
	Title   string   `json:"title"`
	Authors []Author `json:"authors"`
	Pages   int      `json:"pages"`
	Free    bool     `json:"free"`
}

func main() {
	b := Book{
		ISBN:    "9780134190440",
		Title:   "The Go Programming Language",
		Authors: []Author{{"Alan A. A. Donovan"}, {"Brian W. Kernighan"}},
		Pages:   380,
	}

	data, err := json.Marshal(b)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(data))

	pretty, err := json.MarshalIndent(b, "", "  ")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(pretty))

	var back Book
	if err := json.Unmarshal(data, &back); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%+v\n", back)

	// Unmarshal needs a non-nil pointer.
	err = json.Unmarshal(data, (*Book)(nil))
	fmt.Println(err)
}

Output:

{"isbn":"9780134190440","title":"The Go Programming Language","authors":[{"name":"Alan A. A. Donovan"},{"name":"Brian W. Kernighan"}],"pages":380,"free":false}
{
  "isbn": "9780134190440",
  "title": "The Go Programming Language",
  "authors": [
    {
      "name": "Alan A. A. Donovan"
    },
    {
      "name": "Brian W. Kernighan"
    }
  ],
  "pages": 380,
  "free": false
}
{ISBN:9780134190440 Title:The Go Programming Language Authors:[{Name:Alan A. A. Donovan} {Name:Brian W. Kernighan}] Pages:380 Free:false}
json: Unmarshal(nil *main.Book)

Passing a non-pointer to Unmarshal is a classic slip. go vet has a check for it (the unmarshal analyzer), and at run time the call fails with an InvalidUnmarshalError:

	var b Book
	data := []byte(`{"id":"1"}`)
	err := json.Unmarshal(data, b) // should be &b
e23/main.go:10:2: struct field Title repeats json tag "id" also at main.go:9
e23/main.go:16:23: call of Unmarshal passes non-pointer as second argument
json: Unmarshal(non-pointer main.Book)

(The first vet line is a second, deliberate mistake in the same file, covered under Struct tags.)

The Go to JSON mapping

Go type v1 encodes it as v2 encodes it as (default)

bool

true or false

same

string

a JSON string; invalid UTF-8 is replaced by U+FFFD; <, >, &, U+2028 and U+2029 are escaped

a JSON string; invalid UTF-8 is an error; only what the grammar requires is escaped

integers

a number without fraction or exponent

same

float32, float64

a number; NaN and the infinities are errors

same

[]byte

a base64 string (RFC 4648)

same

[N]byte

an array of numbers

a base64 string

other slices and arrays

an array; a nil slice is null

an array; a nil slice is []

map[K]V

an object; keys are strings, integers or `TextMarshaler`s; keys are sorted

an object; key must encode as a string; order is not specified unless Deterministic; a nil map is {}

struct

an object of the exported fields

same, plus errors for invalid structs

pointer, interface

null if nil, else the pointed-to value

same

time.Time

an RFC 3339 string

an RFC 3339 string with nanoseconds

time.Duration

the integer number of nanoseconds

an error (no default representation)

json.Number

the number text

the number text

json.RawMessage (= jsontext.Value)

the raw text, which must be valid JSON

same

complex numbers, channels, functions

an error (UnsupportedTypeError)

an error (SemanticError)

A single program shows most of the v1 column, including the sorted integer keys, the base64 []byte, a nil slice and map as null, and the errors:

package main

import (
	"encoding/json"
	"fmt"
	"math"
	"time"
)

type Sample struct {
	Raw   []byte           `json:"raw"`
	When  time.Time        `json:"when"`
	Ptr   *string          `json:"ptr"`
	Any   any              `json:"any"`
	Ints  map[int]string   `json:"ints"`
	Arr   [2]bool          `json:"arr"`
	Nest  map[string][]int `json:"nest"`
	Float float64          `json:"float"`
}

func main() {
	s := Sample{
		Raw:   []byte("go"),
		When:  time.Date(2026, 10, 18, 9, 30, 0, 0, time.UTC),
		Any:   []any{1, "two", nil},
		Ints:  map[int]string{2: "b", 10: "a", 1: "c"},
		Nest:  map[string][]int{"z": {1}, "a": nil},
		Float: 1e21,
	}
	data, err := json.Marshal(s)
	fmt.Println(string(data), err)

	for _, v := range []any{math.NaN(), make(chan int), func() {}, map[bool]int{true: 1}} {
		_, err := json.Marshal(v)
		fmt.Printf("%T: %v\n", v, err)
	}

	html, _ := json.Marshal("<b>Tom & Jerry</b>
")
	fmt.Println(string(html))
}
{"raw":"Z28=","when":"2026-10-18T09:30:00Z","ptr":null,"any":[1,"two",null],"ints":{"1":"c","10":"a","2":"b"},"arr":[false,false],"nest":{"a":null,"z":[1]},"float":1e+21} <nil>
float64: json: unsupported value: NaN
chan int: json: unsupported type: chan int
func(): json: unsupported type: func()
map[bool]int: json: unsupported value: jsontext: object member name must be a string after offset 2
"\u003cb\u003eTom \u0026 Jerry\u003c/b\u003e\u2028"

The map[bool]int line is the one place where this output depends on the engine: with GOEXPERIMENT=nojsonv2 the message is json: unsupported type: map[bool]int instead of the jsontext: text shown above.

Struct tags

The json key of a struct tag is json:"name,option,option". The first item is the JSON member name; an empty name (json:",omitempty") keeps the Go field name. The options, and a few special cases:

Tag Effect

json:"name"

use name as the object member name

json:"-"

never encode or decode this field

json:"-,"

a field whose JSON name is literally -

json:",omitempty"

marshal: leave the field out when it is empty (v1: false, 0, a nil pointer, a nil interface, or an empty array, slice, map or string; v2: when it would encode as null, "", {} or [])

json:",omitzero"

marshal (Go 1.24): leave the field out when it is the zero value of its type, or when its IsZero() bool method reports true. Works on structs such as time.Time, where omitempty never applies

json:",string"

encode a number (v1: also a bool or string) as a JSON string containing it, to protect 64-bit integers from JavaScript parsers

json:",case:ignore", json:",case:strict"

Go 1.27, unmarshal: choose case-insensitive or exact name matching for this field

json:",embed"

Go 1.27: promote the fields of this field’s struct type into the parent object (a Go embedded struct without a name already does so)

Unexported fields are always skipped (and in v2 may not carry a json tag other than "-"). A struct field of an embedded struct type with no tag is promoted: its fields appear in the parent object, and when names collide the shallowest field wins, as with Go’s selector rules.

package main

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

type Audit struct {
	CreatedBy string `json:"created_by"`
}

type Loan struct {
	Audit              // embedded: fields are promoted into the object
	Book     string    `json:"book"`
	Member   string    `json:"member"`
	Renewals int       `json:"renewals,omitempty"`
	Returned time.Time `json:"returned,omitzero"`
	Fine     int64     `json:"fine,string"`
	Notes    []string  `json:"notes,omitempty"`
	Internal string    `json:"-"`
	Dash     string    `json:"-,"`
	private  string
	NoTag    bool
	Ptr      *int      `json:"ptr"`
	OmitPtr  *int      `json:"omit_ptr,omitempty"`
	ZeroTime time.Time `json:"zero_time_omitempty,omitempty"`
}

func main() {
	l := Loan{
		Audit:    Audit{CreatedBy: "desk-1"},
		Book:     "9780134190440",
		Member:   "m-42",
		Fine:     250,
		Internal: "never written",
		Dash:     "literal dash name",
		private:  "unexported",
		Notes:    []string{},
	}
	data, err := json.MarshalIndent(l, "", "  ")
	fmt.Println(string(data), err)
}
{
  "created_by": "desk-1",
  "book": "9780134190440",
  "member": "m-42",
  "fine": "250",
  "-": "literal dash name",
  "NoTag": false,
  "ptr": null,
  "zero_time_omitempty": "0001-01-01T00:00:00Z"
} <nil>

Look at what this output teaches:

  • Renewals (omitempty, value 0) and Notes (omitempty, empty slice) are omitted, as is OmitPtr; Returned (omitzero, a zero time.Time) is omitted too.

  • ZeroTime is not omitted although it is omitempty: a struct is never "empty" for omitempty, so the zero time is written as "0001-01-01T00:00:00Z". This is exactly why omitzero was added.

  • Fine (,string) is written as "250", a JSON string.

  • Internal (-) is absent, Dash (-,) is written under the name -, NoTag keeps its Go name, private is absent.

  • Ptr is null: nil pointers are written as null unless omitted.

Struct tags are plain strings that the compiler does not check. The structtag analyzer of go vet checks their syntax and duplicate names:

package main

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

type Book struct {
	ISBN  string `json:"id"`
	Title string `json:"id"`
}

func main() {
	b := Book{"1", "Go"}
	data, err := json.Marshal(b)
	fmt.Println(string(data), err)
	data, err = jsonv2.Marshal(b)
	fmt.Println(string(data), err)
	type Bad struct {
		N int             `json:"n,omitEmpty"`
		S struct{ X int } `json:"s,string"`
	}
	data, err = json.Marshal(Bad{})
	fmt.Println(string(data), err)
	data, err = jsonv2.Marshal(Bad{})
	fmt.Println(string(data), err)
}
e24/main.go:11:2: struct field Title repeats json tag "id" also at main.go:10

The program still builds (vet is not part of go run), and the output shows an important difference between the engines. v1 silently drops both conflicting fields and writes {}; it also ignores the misspelled omitEmpty option and the invalid ,string on a struct, so the type "works" but not as intended. v2 reports a runtime error for each (the empty first column is the bytes returned together with the error: never use them):

{} <nil>
 json: cannot marshal from Go main.Book: Go struct fields ISBN and Title conflict over JSON object name "id"
{"n":0,"s":{"X":0}} <nil>
 json: cannot marshal from Go main.Bad: Go struct field N has invalid appearance of `omitEmpty` tag option; specify `omitempty` instead

Optional values and pointers

JSON distinguishes a missing member from null from a zero value; Go structs do not. A pointer field is the usual way to model "not known", combined with omitempty. Go 1.26 lets new take an expression, which makes populating such a field a one-liner:

package main

import (
	"encoding/json"
	"fmt"
)

type Member struct {
	Name string `json:"name"`
	Age  *int   `json:"age,omitempty"` // nil means "not known"
}

func main() {
	// new(expr) is Go 1.26: it allocates a variable initialised to the value.
	known, _ := json.Marshal(Member{Name: "Ada", Age: new(36)})
	unknown, _ := json.Marshal(Member{Name: "Alan"})
	fmt.Println(string(known), string(unknown))

	var m Member
	_ = json.Unmarshal([]byte(`{"name":"Grace","age":0}`), &m)
	fmt.Println(*m.Age)
}
{"name":"Ada","age":36} {"name":"Alan"}
0

A pointer to zero ("age":0) is distinct from a nil pointer: after decoding, *m.Age is 0, not a panic. For nullable columns in a database the equivalent is sql.Null[T] (see database/sql).

Decoding into any, json.Number and large integers

When the target is any, the decoder picks bool, float64, string, []any, map[string]any or nil. Numbers become float64, which silently loses integers above 2^53:

package main

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

func main() {
	const doc = `{"isbn":"9780134190440","copies":3,"id":9007199254740993,"tags":["go","json"],"meta":{"ok":true},"gone":null}`

	var v any
	if err := json.Unmarshal([]byte(doc), &v); err != nil {
		fmt.Println(err)
		return
	}
	m := v.(map[string]any)
	for _, k := range []string{"isbn", "copies", "id", "tags", "meta", "gone"} {
		fmt.Printf("%-6s %-16T %v\n", k, m[k], m[k])
	}

	// UseNumber keeps the digits as a json.Number string.
	dec := json.NewDecoder(strings.NewReader(doc))
	dec.UseNumber()
	var w map[string]any
	if err := dec.Decode(&w); err != nil {
		fmt.Println(err)
		return
	}
	id := w["id"].(json.Number)
	n, err := id.Int64()
	fmt.Println("json.Number:", id, n, err)
	_, err = w["copies"].(json.Number).Float64()
	fmt.Println("as float:", err)

	// A concrete int64 field keeps all digits without UseNumber.
	var s struct {
		ID int64 `json:"id"`
	}
	_ = json.Unmarshal([]byte(doc), &s)
	fmt.Println("int64 field:", s.ID)
}
isbn   string           9780134190440
copies float64          3
id     float64          9.007199254740992e+15
tags   []interface {}   [go json]
meta   map[string]interface {} map[ok:true]
gone   <nil>            <nil>
json.Number: 9007199254740993 9007199254740993 <nil>
as float: <nil>
int64 field: 9007199254740993

Three ways to keep the digits:

  • decode into a concrete type: an int64 field is exact, as the last line shows (both v1 and v2 preserve precision for concrete integer types);

  • Decoder.UseNumber() makes numbers into json.Number, a string type with Int64, Float64 and String methods;

  • represent the number as a string on the wire with the ,string tag option, which is also the safe choice for consumers (JavaScript) that cannot hold 64-bit integers.

Prefer decoding into structs or small purpose-made types over map[string]any: the compiler then checks the field names, and the decoder does the type validation.

Custom marshaling

A type controls its JSON form by implementing one of two interfaces:

type Marshaler interface {
	MarshalJSON() ([]byte, error)
}

type Unmarshaler interface {
	UnmarshalJSON([]byte) error
}

Alternatively, a type may implement encoding.TextMarshaler and encoding.TextUnmarshaler (MarshalText() ([]byte, error) and UnmarshalText([]byte) error); JSON then uses a JSON string with that text, and it also works for map keys, which MarshalJSON does not under v1. Text marshaling is the right choice for enums and identifiers.

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"
)

// ISBN validates its input while decoding.
type ISBN string

var ErrInvalidISBN = errors.New("invalid ISBN")

func (i *ISBN) UnmarshalJSON(b []byte) error {
	var s string
	if err := json.Unmarshal(b, &s); err != nil {
		return err
	}
	d := strings.ReplaceAll(s, "-", "")
	if len(d) != 13 {
		return fmt.Errorf("%w: %q", ErrInvalidISBN, s)
	}
	*i = ISBN(d)
	return nil
}

func (i ISBN) MarshalJSON() ([]byte, error) {
	s := string(i)
	if len(s) == 13 {
		s = s[:3] + "-" + s[3:]
	}
	return json.Marshal(s)
}

// LoanState is written as text, so it also works as a map key.
type LoanState int

const (
	Available LoanState = iota
	OnLoan
	Overdue
)

var stateNames = [...]string{"available", "on_loan", "overdue"}

func (s LoanState) MarshalText() ([]byte, error) {
	if int(s) < 0 || int(s) >= len(stateNames) {
		return nil, fmt.Errorf("unknown loan state %d", int(s))
	}
	return []byte(stateNames[s]), nil
}

func (s *LoanState) UnmarshalText(b []byte) error {
	for i, n := range stateNames {
		if n == string(b) {
			*s = LoanState(i)
			return nil
		}
	}
	return fmt.Errorf("unknown loan state %q", b)
}

type Loan struct {
	Book  ISBN      `json:"book"`
	State LoanState `json:"state"`
}

func main() {
	l := Loan{Book: "9780134190440", State: OnLoan}
	data, err := json.Marshal(l)
	fmt.Println(string(data), err)

	var back Loan
	err = json.Unmarshal([]byte(`{"book":"978-0-13-419044-0","state":"overdue"}`), &back)
	fmt.Println(back, err)

	err = json.Unmarshal([]byte(`{"book":"12","state":"overdue"}`), &back)
	fmt.Println(err, errors.Is(err, ErrInvalidISBN))

	err = json.Unmarshal([]byte(`{"book":"9780134190440","state":"lost"}`), &back)
	fmt.Println(err)

	counts := map[LoanState]int{OnLoan: 2, Overdue: 1}
	data, err = json.Marshal(counts)
	fmt.Println(string(data), err)
}
{"book":"978-0134190440","state":"on_loan"} <nil>
{9780134190440 2} <nil>
invalid ISBN: "12" true
unknown loan state "lost"
{"on_loan":2,"overdue":1} <nil>

Rules to remember:

  • UnmarshalJSON must have a pointer receiver, because it mutates the value. MarshalJSON may use a value or a pointer receiver, but under v1 a pointer-receiver MarshalJSON is only called when the value being encoded is addressable: a struct reached through a pointer or a slice element, not one held in an any, a map value or a returned value. Use a value receiver for Marshal* methods unless you must mutate (see Methods and Receivers). v2 always calls the method.

  • Inside MarshalJSON, calling json.Marshal on the receiver itself recurses forever (until the stack overflows). To reuse the default encoding, convert to a defined type without the methods and marshal that, as in the next example.

  • An UnmarshalJSON that returns an error aborts decoding; under v1 the error is returned unwrapped.

  • Embedding a type that has MarshalJSON promotes the method, so the outer struct is encoded with the inner type’s method and the outer fields vanish (see Composition and Object-Oriented Design).

The "plain type" conversion also gives you defaults while decoding, and a custom date type shows the two methods working with omitzero (the embedded time.Time promotes IsZero):

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"time"
)

// Member fills in defaults while decoding, by decoding into an alias
// type that has no UnmarshalJSON method of its own.
type Member struct {
	Name  string `json:"name"`
	Limit int    `json:"limit"`
}

func (m *Member) UnmarshalJSON(b []byte) error {
	type plain Member // same fields, none of the methods
	p := plain{Limit: 5}
	if err := json.Unmarshal(b, &p); err != nil {
		return err
	}
	*m = Member(p)
	return nil
}

// Date is a calendar date written as 2006-01-02. The embedded time.Time
// promotes IsZero, which is the method omitzero asks.
type Date struct{ time.Time }

func (d Date) MarshalJSON() ([]byte, error) {
	return json.Marshal(d.Format(time.DateOnly))
}

func (d *Date) UnmarshalJSON(b []byte) error {
	var s string
	if err := json.Unmarshal(b, &s); err != nil {
		return err
	}
	t, err := time.Parse(time.DateOnly, s)
	d.Time = t
	return err
}

type Loan struct {
	Book string `json:"book"`
	Due  Date   `json:"due,omitzero"`
}

func main() {
	var m Member
	err := json.Unmarshal([]byte(`{"name":"Ada"}`), &m)
	fmt.Printf("%+v %v\n", m, err)

	data, err := json.Marshal(Loan{Book: "1", Due: Date{time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC)}})
	fmt.Println(string(data), err)
	data, err = json.Marshal(Loan{Book: "1"})
	fmt.Println(string(data), err)

	var l Loan
	err = json.Unmarshal([]byte(`{"book":"2","due":"2026-13-45"}`), &l)
	fmt.Println(err)

	// Helpers that work on bytes.
	src := []byte(`{ "title" : "R&D <v2>" , "tags":[ "a" , "b" ] }`)
	fmt.Println(json.Valid(src), json.Valid([]byte(`{"a":}`)))

	var buf bytes.Buffer
	_ = json.Compact(&buf, src)
	fmt.Println(buf.String())

	buf.Reset()
	_ = json.Indent(&buf, src, ">", "  ")
	fmt.Println(buf.String())

	buf.Reset()
	json.HTMLEscape(&buf, []byte(`{"t":"<b>&</b>"}`))
	fmt.Println(buf.String())
}
{Name:Ada Limit:5} <nil>
{"book":"1","due":"2026-11-01"} <nil>
{"book":"1"} <nil>
parsing time "2026-13-45": month out of range
true false
{"title":"R&D <v2>","tags":["a","b"]}
{
>  "title": "R&D <v2>",
>  "tags": [
>    "a",
>    "b"
>  ]
>}
{"t":"\u003cb\u003e\u0026\u003c/b\u003e"}

Encoder and Decoder: streams

json.NewEncoder(w) and json.NewDecoder(r) work on io.Writer and io.Reader values, so they fit files, network connections and HTTP bodies without reading everything into a []byte. A decoder reads a stream of values separated by whitespace (newline-delimited JSON, "JSON lines", is the common case) and returns io.EOF at the end of input. It can also walk a large array token by token with Token, More and Decode, keeping only one element in memory. DisallowUnknownFields makes a decoder reject members the struct does not have. An encoder adds a newline after every value and offers SetIndent and SetEscapeHTML.

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"strings"
)

type Book struct {
	ISBN  string `json:"isbn"`
	Title string `json:"title"`
}

func main() {
	// 1. Newline-delimited values: Decode returns io.EOF at the end.
	const lines = `{"isbn":"1","title":"A"}
{"isbn":"2","title":"B"}
{"isbn":"3","title":"C"}
`
	dec := json.NewDecoder(strings.NewReader(lines))
	for {
		var b Book
		err := dec.Decode(&b)
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			fmt.Println("error:", err)
			return
		}
		fmt.Printf("%+v (offset %d)\n", b, dec.InputOffset())
	}

	// 2. Reject unknown fields.
	strict := json.NewDecoder(strings.NewReader(`{"isbn":"1","title":"A","pages":9}`))
	strict.DisallowUnknownFields()
	var b Book
	fmt.Println("unknown:", strict.Decode(&b))

	// 3. Stream a big array one element at a time.
	arr := json.NewDecoder(strings.NewReader(`[{"isbn":"1","title":"A"},{"isbn":"2","title":"B"}]`))
	tok, err := arr.Token()
	fmt.Printf("%T %v %v\n", tok, tok, err)
	for arr.More() {
		var b Book
		if err := arr.Decode(&b); err != nil {
			fmt.Println(err)
			return
		}
		fmt.Println("element:", b.Title)
	}
	tok, err = arr.Token()
	fmt.Printf("%T %v %v\n", tok, tok, err)

	// 4. Encoder: one value per call, newline added, indentation and HTML escaping configurable.
	enc := json.NewEncoder(os.Stdout)
	enc.SetEscapeHTML(false)
	enc.SetIndent("", "\t")
	if err := enc.Encode(Book{"3", "R&D <draft>"}); err != nil {
		fmt.Println(err)
	}
}
{ISBN:1 Title:A} (offset 24)
{ISBN:2 Title:B} (offset 49)
{ISBN:3 Title:C} (offset 74)
unknown: json: unknown field "pages"
json.Delim [ <nil>
element: A
element: B
json.Delim ] <nil>
{
	"isbn": "3",
	"title": "R&D <draft>"
}

Notes:

  • Decoder.Decode stops after one value: bytes after it are left in the reader’s buffer. To check that a body holds exactly one value, call Decode again and require io.EOF (shown in the HTTP example below).

  • Decoder.Buffered returns the data read but not yet consumed, and InputOffset the byte offset of the decoder.

  • SetEscapeHTML(false) keeps <, > and & literal. v1 escapes them by default because the output may be embedded in HTML; json.HTMLEscape does the same on existing text.

  • Encoder.Encode is the right call when writing many values to a stream; for a single value to a Writer, v2’s MarshalWrite is shorter.

Errors

Decoding errors are values of specific types, so errors.As can tell a syntax problem from a type mismatch:

Type Meaning

*json.SyntaxError

malformed JSON; Offset is the byte offset where the problem was found

*json.UnmarshalTypeError

a JSON value does not fit the Go type; fields Value, Type, Offset, Struct, Field. Decoding continues, so the rest of the value is still stored and the first such error is returned

*json.InvalidUnmarshalError

the destination is nil or not a pointer

*json.UnmarshalFieldError

legacy; no longer produced

*json.UnsupportedTypeError, *json.UnsupportedValueError

marshaling a channel, function, complex number, NaN or a cyclic value

*json.MarshalerError

a MarshalJSON or MarshalText method failed (v1 wraps the error, with the Err field)

*json.InvalidUTF8Error

legacy; no longer produced

package main

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

type Book struct {
	Title string `json:"title"`
	Pages int    `json:"pages"`
}

func main() {
	var b Book

	err := json.Unmarshal([]byte(`{"title": "A", "pages": }`), &b)
	var se *json.SyntaxError
	if errors.As(err, &se) {
		fmt.Printf("syntax error at offset %d: %v\n", se.Offset, se)
	}

	err = json.Unmarshal([]byte(`{"title":"A","pages":"many"}`), &b)
	var te *json.UnmarshalTypeError
	if errors.As(err, &te) {
		fmt.Printf("type error: value=%s type=%v field=%q offset=%d\n", te.Value, te.Type, te.Field, te.Offset)
		fmt.Println(err)
	}
	fmt.Printf("partial result still filled: %+v\n", b)

	var missing *Book
	err = json.Unmarshal([]byte(`{}`), missing)
	var ie *json.InvalidUnmarshalError
	fmt.Println(errors.As(err, &ie), err)

	fmt.Println(json.Valid([]byte(`{"a":1}`)), json.Valid([]byte(`{a:1}`)))
}
syntax error at offset 25: invalid character '}' looking for beginning of value
type error: value=string type=int field="pages" offset=27
json: cannot unmarshal string into Go struct field Book.pages of type int
partial result still filled: {Title:A Pages:0}
true json: Unmarshal(nil *main.Book)
true false

Note the last result of the type error: the Title field was still decoded. After a decoding error, treat the target as partially filled and discard it unless you have a reason not to.

RawMessage: decoding in two steps

json.RawMessage (a []byte; in Go 1.27 it is a type alias for jsontext.Value) holds JSON text without decoding it. It is the standard tool for envelopes whose payload type depends on another field, for passing opaque JSON through, and for decoding a large document lazily.

package main

import (
	"encoding/json"
	"fmt"
)

type Event struct {
	Type string          `json:"type"`
	Data json.RawMessage `json:"data"`
}

type Borrowed struct {
	Member string `json:"member"`
	Book   string `json:"book"`
}

type Returned struct {
	Book string `json:"book"`
	Late bool   `json:"late"`
}

func decode(raw string) {
	var e Event
	if err := json.Unmarshal([]byte(raw), &e); err != nil {
		fmt.Println("envelope:", err)
		return
	}
	switch e.Type {
	case "borrowed":
		var b Borrowed
		err := json.Unmarshal(e.Data, &b)
		fmt.Printf("%s: %+v %v\n", e.Type, b, err)
	case "returned":
		var r Returned
		err := json.Unmarshal(e.Data, &r)
		fmt.Printf("%s: %+v %v\n", e.Type, r, err)
	default:
		fmt.Printf("unknown event %q, payload kept as %s\n", e.Type, e.Data)
	}
}

func main() {
	decode(`{"type":"borrowed","data":{"member":"m-42","book":"9780134190440"}}`)
	decode(`{"type":"returned","data":{"book":"9780134190440","late":true}}`)
	decode(`{"type":"reserved","data":  [1, 2,   3]}`)

	// RawMessage marshals as-is, but must be valid JSON.
	out, err := json.Marshal(Event{Type: "x", Data: json.RawMessage(`{"a":1}`)})
	fmt.Println(string(out), err)
	_, err = json.Marshal(Event{Type: "x", Data: json.RawMessage(`{a:1}`)})
	fmt.Println(err)
}
borrowed: {Member:m-42 Book:9780134190440} <nil>
returned: {Book:9780134190440 Late:true} <nil>
unknown event "reserved", payload kept as [1, 2,   3]
{"type":"x","data":{"a":1}} <nil>
json: error calling MarshalJSON for type *jsontext.Value: invalid character 'a' looking for beginning of object key string

The last line is the one engine-dependent message in this output: with GOEXPERIMENT=nojsonv2 it names json.RawMessage where the default build names *jsontext.Value, which is the alias made visible. RawMessage is marshaled verbatim, so it must hold valid JSON: the invalid text in the example is rejected rather than copied into the output.

Reformatting and validating text

json.Valid(data) reports whether data is a valid JSON text, json.Compact removes insignificant whitespace, json.Indent(dst, src, prefix, indent) formats, and json.HTMLEscape escapes <, >, &, U+2028 and U+2029 (the last four calls of the custom-marshaling example above show all four). They operate on bytes and a *bytes.Buffer; the jsontext package has richer equivalents for v2 (below).

A JSON endpoint with v1

A handler that accepts a JSON body should limit its size, reject unknown fields, and check for trailing data. The combination below uses only v1, and runs against httptest (no network):

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
)

type Book struct {
	ISBN  string `json:"isbn"`
	Title string `json:"title"`
}

func decodeBody(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		return err
	}
	// A second Decode must hit EOF, otherwise there is trailing data.
	if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		return errors.New("body must contain a single JSON value")
	}
	return nil
}

func addBook(w http.ResponseWriter, r *http.Request) {
	var b Book
	if err := decodeBody(w, r, &b); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	if err := json.NewEncoder(w).Encode(b); err != nil {
		// Headers are sent; all we can do is log.
		fmt.Println("write:", err)
	}
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /books", addBook)

	for _, body := range []string{
		`{"isbn":"1","title":"A"}`,
		`{"isbn":"1","title":"A","extra":true}`,
		`{"isbn":"1","title":"A"} {"x":1}`,
		`{"isbn":`,
		`{"isbn":"` + strings.Repeat("x", 2000) + `"}`,
	} {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest("POST", "/books", strings.NewReader(body))
		mux.ServeHTTP(rec, req)
		out := strings.TrimSpace(rec.Body.String())
		if len(out) > 70 {
			out = out[:70] + "..."
		}
		fmt.Printf("%d %s\n", rec.Code, out)
	}
}
201 {"isbn":"1","title":"A"}
400 json: unknown field "extra"
400 body must contain a single JSON value
400 unexpected EOF
400 http: request body too large

http.MaxBytesReader turns an oversized body into an error (a *http.MaxBytesError can be matched with errors.As) and tells the server to close the connection. See net/http Clients and Servers for the full server, timeouts and graceful shutdown, and CORS for the browser side of a JSON API.

encoding/json/v2

The API

The v2 package is imported as "encoding/json/v2"; its package name is json, so programs that also use v1 give one of them an alias (jsonv1 "encoding/json" or jsonv2 "encoding/json/v2"). The function set:

Function Purpose

Marshal(in any, opts ...Options) ([]byte, error)

encode to a byte slice (no trailing newline)

Unmarshal(in []byte, out any, opts ...Options) error

decode one JSON value; out must be a non-nil pointer

MarshalWrite(out io.Writer, in any, opts ...Options) error

encode straight to a writer

UnmarshalRead(in io.Reader, out any, opts ...Options) error

decode a single value from a reader; it consumes the whole reader until io.EOF, so anything after the value is a syntax error

MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error

encode one value into a jsontext.Encoder (streams of values)

UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error

decode the next value from a jsontext.Decoder

JoinOptions, GetOption, DefaultOptionsV2

combine options, read one back, reset to the v2 defaults

WithMarshalers, WithUnmarshalers, MarshalFunc, MarshalToFunc, UnmarshalFunc, UnmarshalFromFunc, JoinMarshalers, JoinUnmarshalers

per-call, per-type customization

The options are the new idea. Every function takes any number of json.Options (and jsontext.Options, which are the same type), so behaviour that v1 hard-coded is chosen per call; later options override earlier ones. The marshal and unmarshal options of v2 itself:

Option Effect

json.Deterministic(true)

sort map keys (and make output reproducible); required for stable output in tests and signatures

json.FormatNilSliceAsNull(true), json.FormatNilMapAsNull(true)

write nil slices and maps as null instead of [] and {}

json.MatchCaseInsensitiveNames(true)

unmarshal object names into struct fields ignoring case (and -, _)

json.RejectUnknownMembers(true)

unmarshal: an object member with no matching field is an error (json.ErrUnknownName)

json.StringifyNumbers(true)

encode every number as a JSON string, like the ,string tag on every field

json.OmitZeroStructFields(true)

omit struct fields that are the zero value everywhere, as if each had omitzero

json.WithMarshalers(m), json.WithUnmarshalers(u)

type-specific functions used instead of the type’s methods

The jsontext options (syntax level) such as jsontext.Multiline(true), jsontext.WithIndent(" "), jsontext.AllowDuplicateNames(true), jsontext.AllowInvalidUTF8(true), jsontext.EscapeForHTML(true), jsontext.EscapeForJS(true), jsontext.SpaceAfterColon(true) and jsontext.SpaceAfterComma(true) can be passed to the same functions and decide how the text is produced or how strictly it is read.

package main

import (
	"bytes"
	"encoding/json/jsontext"
	"encoding/json/v2"
	"errors"
	"fmt"
	"strings"
)

type Book struct {
	ISBN  string   `json:"isbn"`
	Title string   `json:"title"`
	Tags  []string `json:"tags"`
	Pages int      `json:"pages,omitzero"`
}

func main() {
	b := Book{ISBN: "9780134190440", Title: "The Go Programming Language"}

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

	// Options are variadic and can be joined.
	data, err = json.Marshal(b,
		json.FormatNilSliceAsNull(true),
		jsontext.Multiline(true),
		jsontext.WithIndent("  "))
	fmt.Println(string(data), err)

	// Streaming: MarshalWrite and UnmarshalRead.
	var buf bytes.Buffer
	if err := json.MarshalWrite(&buf, b, json.Deterministic(true)); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println(buf.String())

	var back Book
	err = json.UnmarshalRead(&buf, &back)
	fmt.Printf("%+v %v\n", back, err)

	// Strictness: reject unknown members.
	err = json.Unmarshal([]byte(`{"isbn":"1","colour":"red"}`), &back, json.RejectUnknownMembers(true))
	fmt.Println(err, errors.Is(err, json.ErrUnknownName))

	// The error carries the JSON Pointer of the offending value.
	err = json.Unmarshal([]byte(`{"isbn":"1","tags":["a",7]}`), &back)
	var se *json.SemanticError
	if errors.As(err, &se) {
		fmt.Println(se.JSONPointer, se.JSONKind, se.GoType, "|", err)
	}

	// Compare with the first byte on a reader that holds two values.
	err = json.UnmarshalRead(strings.NewReader(`{"isbn":"1"} {"isbn":"2"}`), &back)
	fmt.Println(err)
}
{"isbn":"9780134190440","title":"The Go Programming Language","tags":[]} <nil>
{
  "isbn": "9780134190440",
  "title": "The Go Programming Language",
  "tags": null
} <nil>
{"isbn":"9780134190440","title":"The Go Programming Language","tags":[]}
{ISBN:9780134190440 Title:The Go Programming Language Tags:[] Pages:0} <nil>
json: cannot unmarshal JSON string into Go main.Book: unknown object member name "colour" true
/tags/1 number string | json: cannot unmarshal JSON number into Go string within "/tags/1"
jsontext: invalid character '{' after top-level value after offset 13

What this shows:

  • The default output of a nil slice is [] (the tags member), and FormatNilSliceAsNull changes it for one call.

  • Multiline plus WithIndent pretty-prints without a separate MarshalIndent.

  • MarshalWrite and UnmarshalRead replace the Encoder and Decoder for one-shot work (one value per reader or writer; note that MarshalWrite does not append a newline).

  • RejectUnknownMembers produces an error that matches json.ErrUnknownName with errors.Is.

  • A type mismatch is a *json.SemanticError: JSONPointer (here /tags/1, an RFC 6901 pointer), JSONKind, GoType, ByteOffset, and the wrapped Err. jsontext.SyntacticError plays the same role for malformed text, so a handler can answer 400 for syntax and 422 for semantics (see the endpoint example below).

  • UnmarshalRead rejects a second value in the input (invalid character '{' after top-level value): it is for one value per reader, not for streams (use a jsontext.Decoder and UnmarshalDecode for streams).

The returned []byte on error is not meaningful (v2 may return the bytes produced so far, as the demonstrations on this page show); check err first.

Struct tags in v2

The tag vocabulary is the same as v1 plus the Go 1.27 additions:

Option v2 meaning

name

the object member name; matching is case-sensitive unless case:ignore or MatchCaseInsensitiveNames

omitzero

omit when zero (Go zero value or IsZero() bool); identical to v1

omitempty

omit when the field would encode as null, "", {} or []. Differs from v1: 0, false and a pointer to a zero value are not omitted; use omitzero for those

string

quote a value that encodes as a JSON number; error for a type that does not encode as a number

case:ignore, case:strict

name matching for unmarshaling, per field; ignore also ignores dashes and underscores

embed

promote the content of the field’s type (a struct, jsontext.Value, map[~string]T or an unnamed pointer to one) into the parent object

embed of a map[string]T or jsontext.Value field is the fallback feature: it collects every member the struct does not name, which is how you round-trip unknown members, and at most one fallback is allowed in a struct. Using an unknown spelling that resembles an option (omitEmpty, omit_empty) is rejected at run time, as shown earlier.

package main

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

type Audit struct {
	CreatedBy string `json:"created_by"`
}

type Loan struct {
	Audit
	Book     string    `json:"book"`
	Member   string    `json:"member"`
	Renewals int       `json:"renewals,omitempty"`
	Returned time.Time `json:"returned,omitzero"`
	Fine     int64     `json:"fine,string"`
	Notes    []string  `json:"notes,omitempty"`
	Tags     []string  `json:"tags,omitzero"`
	Internal string    `json:"-"`
	Fixed    string    `json:"fixed,case:strict"`
	Loose    string    `json:"loose_name,case:ignore"`
}

func main() {
	l := Loan{
		Audit:  Audit{CreatedBy: "desk-1"},
		Book:   "9780134190440",
		Member: "m-42",
		Fine:   250,
		Notes:  []string{},
		Tags:   []string{},
	}
	data, err := json.Marshal(l, jsontext.Multiline(true), jsontext.WithIndent("  "))
	fmt.Println(string(data), err)

	// case:ignore ignores case, dashes and underscores; case:strict is the default.
	var back Loan
	err = json.Unmarshal([]byte(`{"FIXED":"x","Loose-Name":"y","BOOK":"z"}`), &back)
	fmt.Printf("%+v %v\n", back, err)

	// The "format" tag option of the 1.25 experiment is gone: it is a marshal error now.
	type Stamp struct {
		At time.Time `json:"at,format:unixmilli"`
	}
	_, err = json.Marshal(Stamp{time.UnixMilli(5000).UTC()})
	fmt.Println(err)
}
{
  "created_by": "desk-1",
  "book": "9780134190440",
  "member": "m-42",
  "renewals": 0,
  "fine": "250",
  "tags": [],
  "fixed": "",
  "loose_name": ""
} <nil>
{Audit:{CreatedBy:} Book: Member: Renewals:0 Returned:0001-01-01 00:00:00 +0000 UTC Fine:0 Notes:[] Tags:[] Internal: Fixed: Loose:y} <nil>
json: cannot marshal from Go main.Stamp: Go struct field At has unsupported `format` tag option

Things to notice:

  • Renewals is omitempty and 0, and it is written ("renewals": 0), unlike v1 and unlike the Notes slice, which is omitted because an empty slice is an "empty JSON array". The v2 rule is in terms of the JSON value.

  • Tags is omitzero and []string{}: a non-nil empty slice is not the zero value, so it is written. A nil slice would be omitted.

  • The case options act on decoding: FIXED does not match the case:strict field, while Loose-Name matches the case:ignore field loose_name; BOOK does not match Book (exact names by default), so those fields stay empty.

  • The format tag option from the Go 1.25 experiment (for example json:"at,format:unixmilli") no longer exists in 1.27; the library rejects it. See the removed experiment features.

  • The error text reads cannot marshal in some runs and unable to marshal in others. Once per process, v2 deliberately picks one of the two equivalent wordings, so that no program depends on the exact message. Match the error with errors.As and a *json.SemanticError instead of comparing its string.

The next example shows both kinds of fallback, a per-call Marshalers/Unmarshalers pair, and the new method interfaces:

package main

import (
	"encoding/json/jsontext"
	"encoding/json/v2"
	"errors"
	"fmt"
	"strings"
	"time"
)

// Settings keeps members it does not know about instead of dropping them.
type Settings struct {
	Name string         `json:"name"`
	Rest map[string]any `json:",embed"`
}

// Raw keeps the unknown members as raw JSON text.
type Raw struct {
	Name string         `json:"name"`
	Rest jsontext.Value `json:",embed"`
}

// Policy holds a time.Duration, which has no default JSON form in v2.
type Policy struct {
	Name   string        `json:"name"`
	Period time.Duration `json:"period"`
}

// ISBN implements MarshalerTo and UnmarshalerFrom to avoid intermediate allocations.
type ISBN string

func (i ISBN) MarshalJSONTo(enc *jsontext.Encoder) error {
	return enc.WriteToken(jsontext.String(string(i)))
}

func (i *ISBN) UnmarshalJSONFrom(dec *jsontext.Decoder) error {
	tok, err := dec.ReadToken()
	if err != nil {
		return err
	}
	if tok.Kind() != '"' {
		return fmt.Errorf("ISBN must be a JSON string, got %v", tok.Kind())
	}
	s := strings.ReplaceAll(tok.String(), "-", "")
	if len(s) != 13 {
		return fmt.Errorf("invalid ISBN %q", tok.String())
	}
	*i = ISBN(s)
	return nil
}

func main() {
	var s Settings
	err := json.Unmarshal([]byte(`{"name":"desk","theme":"dark","limit":3}`), &s)
	fmt.Printf("%+v %v\n", s, err)
	out, _ := json.Marshal(s, json.Deterministic(true))
	fmt.Println(string(out))

	var r Raw
	err = json.Unmarshal([]byte(`{"name":"desk","theme":"dark","limit":3}`), &r)
	fmt.Printf("%s | %s %v\n", r.Name, r.Rest, err)

	// Per-call marshalers: a function for a type you do not own.
	opts := json.JoinOptions(
		json.WithMarshalers(json.MarshalToFunc(func(enc *jsontext.Encoder, d time.Duration) error {
			return enc.WriteToken(jsontext.String(d.String()))
		})),
		json.WithUnmarshalers(json.UnmarshalFromFunc(func(dec *jsontext.Decoder, d *time.Duration) error {
			tok, err := dec.ReadToken()
			if err != nil {
				return err
			}
			v, err := time.ParseDuration(tok.String())
			*d = v
			return err
		})),
	)
	p := Policy{Name: "loan", Period: 36 * time.Hour}
	out, err = json.Marshal(p, opts)
	fmt.Println(string(out), err)
	var p2 Policy
	err = json.Unmarshal(out, &p2, opts)
	fmt.Printf("%+v %v\n", p2, err)

	// Without the option, Duration has no default representation.
	_, err = json.Marshal(p)
	fmt.Println(err)

	// Methods on the type.
	var ids []ISBN
	err = json.Unmarshal([]byte(`["978-0-13-419044-0","9780134190440"]`), &ids)
	fmt.Println(ids, err)
	out, err = json.Marshal(ids)
	fmt.Println(string(out), err)
	err = json.Unmarshal([]byte(`[1]`), &ids)
	var se *json.SemanticError
	fmt.Println(errors.As(err, &se), err)
}
{Name:desk Rest:map[limit:3 theme:dark]} <nil>
{"name":"desk","limit":3,"theme":"dark"}
desk | {"theme":"dark","limit":3} <nil>
{"name":"loan","period":"36h0m0s"} <nil>
{Name:loan Period:36h0m0s} <nil>
json: cannot marshal from Go time.Duration within "/period": no default representation
[9780134190440 9780134190440] <nil>
["9780134190440","9780134190440"] <nil>
true json: cannot unmarshal into Go main.ISBN within "/0": ISBN must be a JSON string, got number

Customization in v2 has three layers, from highest precedence to lowest:

  1. functions in WithMarshalers or WithUnmarshalers (MarshalFunc, MarshalToFunc, UnmarshalFunc, UnmarshalFromFunc): per call, and usable for types you do not own (such as time.Duration);

  2. methods MarshalJSONTo(*jsontext.Encoder) error (MarshalerTo) and UnmarshalJSONFrom(*jsontext.Decoder) error (UnmarshalerFrom): they write and read tokens directly, without building an intermediate []byte, and may return errors.ErrUnsupported to fall back to the default representation;

  3. the v1 interfaces MarshalJSON, UnmarshalJSON (still honoured), then encoding.TextAppender and encoding.TextMarshaler (and TextUnmarshaler), then the built-in representation.

Marshal methods should assume Deterministic is true and return deterministic output, must write exactly one JSON value, must not retain the encoder or decoder pointer, and should call MarshalEncode / UnmarshalDecode (not the methods directly) for contained values so that options such as WithMarshalers keep applying.

Behaviour differences, side by side

The most reliable way to learn the differences is to run the same inputs through both packages. The program below prints one line per behaviour (the Holder type has a pointer-receiver MarshalJSON):

package main

import (
	"encoding/json"
	jsonv2 "encoding/json/v2"
	"fmt"
	"strings"
	"time"
)

type Book struct {
	Title string   `json:"title"`
	Tags  []string `json:"tags"`
	Meta  map[string]string
	Pages int  `json:"pages,omitempty"`
	Free  bool `json:"free,omitempty"`
}

// Level has a pointer-receiver MarshalJSON.
type Level int

func (l *Level) MarshalJSON() ([]byte, error) { return []byte(`"L` + fmt.Sprint(int(*l)) + `"`), nil }

type Holder struct{ Lv Level }

type Flag struct {
	On bool `json:"on,string"`
}

func show(name string, v1, v2 string) {
	fmt.Printf("%-22s v1: %-48s v2: %s\n", name, v1, v2)
}

func res(b []byte, err error) string {
	if err != nil {
		return "ERROR " + shorten(err.Error())
	}
	return string(b)
}

func shorten(s string) string {
	if len(s) > 70 {
		return s[:70] + "..."
	}
	return s
}

func main() {
	b := Book{Title: "Go"}
	show("nil slice and map", res(json.Marshal(b)), res(jsonv2.Marshal(b)))

	var u1, u2 Book
	e1 := json.Unmarshal([]byte(`{"TITLE":"loud"}`), &u1)
	e2 := jsonv2.Unmarshal([]byte(`{"TITLE":"loud"}`), &u2)
	show("name case", fmt.Sprintf("%q %v", u1.Title, e1), fmt.Sprintf("%q %v", u2.Title, e2))

	u1, u2 = Book{}, Book{}
	e1 = json.Unmarshal([]byte(`{"title":"a","title":"b"}`), &u1)
	e2 = jsonv2.Unmarshal([]byte(`{"title":"a","title":"b"}`), &u2)
	show("duplicate name", fmt.Sprintf("%q %v", u1.Title, e1), fmt.Sprintf("%q %s", u2.Title, shorten(fmt.Sprint(e2))))

	u1, u2 = Book{}, Book{}
	bad := "{\"title\":\"a\xffb\"}"
	e1 = json.Unmarshal([]byte(bad), &u1)
	e2 = jsonv2.Unmarshal([]byte(bad), &u2)
	show("invalid UTF-8", fmt.Sprintf("%+q %v", u1.Title, e1), fmt.Sprintf("%+q %s", u2.Title, shorten(fmt.Sprint(e2))))

	show("duration", res(json.Marshal(time.Second)), res(jsonv2.Marshal(time.Second)))
	show("[2]byte", res(json.Marshal([2]byte{1, 2})), res(jsonv2.Marshal([2]byte{1, 2})))
	show("HTML characters", res(json.Marshal("<a&b>")), res(jsonv2.Marshal("<a&b>")))
	show("pointer receiver", res(json.Marshal(Holder{3})), res(jsonv2.Marshal(Holder{3})))
	show("string tag on bool", res(json.Marshal(Flag{true})), res(jsonv2.Marshal(Flag{true})))
	show("no exported fields", res(json.Marshal(struct{ x int }{1})), res(jsonv2.Marshal(struct{ x int }{1})))

	m := map[string]int{"b": 2, "a": 1, "c": 3}
	show("map key order", res(json.Marshal(m)), res(jsonv2.Marshal(m, jsonv2.Deterministic(true))))

	x1, x2 := Book{Pages: 7}, Book{Pages: 7}
	e1 = json.Unmarshal([]byte(`{"pages":null}`), &x1)
	e2 = jsonv2.Unmarshal([]byte(`{"pages":null}`), &x2)
	show("null into int", fmt.Sprint(x1.Pages, e1), fmt.Sprint(x2.Pages, e2))

	var a1, a2 any
	_ = json.NewDecoder(strings.NewReader(`{"n":1}`)).Decode(&a1)
	_ = jsonv2.Unmarshal([]byte(`{"n":1}`), &a2)
	show("number into any", fmt.Sprintf("%T", a1.(map[string]any)["n"]), fmt.Sprintf("%T", a2.(map[string]any)["n"]))
}
nil slice and map      v1: {"title":"Go","tags":null,"Meta":null}           v2: {"title":"Go","tags":[],"Meta":{},"pages":0,"free":false}
name case              v1: "loud" <nil>                                     v2: "" <nil>
duplicate name         v1: "b" <nil>                                        v2: "a" jsontext: duplicate object member name "title"
invalid UTF-8          v1: "a\ufffdb" <nil>                                 v2: "" jsontext: invalid UTF-8 within "/title" after offset 11
duration               v1: 1000000000                                       v2: ERROR json: cannot marshal from Go time.Duration: no default representation
[2]byte                v1: [1,2]                                            v2: "AQI="
HTML characters        v1: "\u003ca\u0026b\u003e"                           v2: "<a&b>"
pointer receiver       v1: {"Lv":3}                                         v2: {"Lv":"L3"}
string tag on bool     v1: {"on":"true"}                                    v2: ERROR json: cannot marshal from Go bool within "/on": invalid use of `string...
no exported fields     v1: {}                                               v2: ERROR json: cannot marshal from Go struct { x int }: Go struct has no export...
map key order          v1: {"a":1,"b":2,"c":3}                              v2: {"a":1,"b":2,"c":3}
null into int          v1: 7 <nil>                                          v2: 0 <nil>
number into any        v1: float64                                          v2: float64

Read it row by row:

  • nil slice and map are null in v1 and []/{} in v2; and because of the v2 omitempty rule pages and free (0 and false) are written.

  • Name case: v1 matched TITLE to title; v2 did not.

  • Duplicate names are accepted by v1 (last wins) and rejected by v2.

  • Invalid UTF-8 is silently replaced by U+FFFD in v1 and rejected in v2 (jsontext: invalid UTF-8 ...).

  • Duration: v1 writes nanoseconds, v2 refuses (use json.FormatDurationAsNano, a custom marshaler or a string).

  • [2]byte: an array in v1, a base64 string ("AQI=") in v2.

  • HTML characters: v1 escapes <, > and & (<), v2 writes the string as is.

  • Pointer receiver: v1 does not call (*Level).MarshalJSON for a non-addressable value and writes 3; v2 always calls it and writes "L3".

  • ,string on a bool is accepted by v1 and an error in v2.

  • A struct with no exported fields is {} in v1 and an error in v2.

  • null leaves an existing int unchanged in v1 (7) and zeroes it in v2.

  • Map key order is sorted in v1; with Deterministic(true) v2 matches it, and without it the order is unspecified.

The same information as a table, including the option that restores each v1 behaviour:

Table comparing the defaults of encoding/json v1 and v2 and the option that selects the other behaviour

The official wording of every difference, with the exact semantics, is in the encoding/json package documentation ("Migrating to v2") and the migration guide.

A strict JSON endpoint with v2

The v2 defaults and UnmarshalRead shorten the handler of the v1 section, and the error types allow a precise status code. The example also streams a JSON array element by element with a jsontext.Decoder and MarshalEncode:

package main

import (
	"encoding/json/jsontext"
	"encoding/json/v2"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
)

type Book struct {
	ISBN  string `json:"isbn"`
	Title string `json:"title"`
	Pages int    `json:"pages,omitzero"`
}

// readJSON decodes one JSON value from the request body, strictly.
func readJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
	return json.UnmarshalRead(r.Body, dst, json.RejectUnknownMembers(true))
}

func addBook(w http.ResponseWriter, r *http.Request) {
	var b Book
	if err := readJSON(w, r, &b); err != nil {
		var tooBig *http.MaxBytesError
		var syn *jsontext.SyntacticError
		var sem *json.SemanticError
		switch {
		case errors.As(err, &tooBig):
			http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
		case errors.As(err, &syn):
			http.Error(w, "malformed JSON", http.StatusBadRequest)
		case errors.As(err, &sem):
			http.Error(w, fmt.Sprintf("invalid value at %s", sem.JSONPointer), http.StatusUnprocessableEntity)
		default:
			http.Error(w, "unreadable body", http.StatusBadRequest)
		}
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	if err := json.MarshalWrite(w, b); err != nil {
		fmt.Println("write:", err)
	}
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /books", addBook)
	for _, body := range []string{
		`{"isbn":"1","title":"A"}`,
		`{"isbn":"1","title":"A","extra":true}`,
		`{"isbn":"1","title":7}`,
		`{"isbn":"1","title":"A"} {"x":1}`,
		`{"isbn":`,
		`{"isbn":"1","isbn":"2"}`,
		`{"isbn":"` + strings.Repeat("x", 2000) + `"}`,
	} {
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, httptest.NewRequest("POST", "/books", strings.NewReader(body)))
		out := strings.TrimSpace(rec.Body.String())
		fmt.Printf("%d %s\n", rec.Code, out)
	}

	// Streaming a large array: one element in memory at a time.
	dec := jsontext.NewDecoder(strings.NewReader(`[{"isbn":"1","title":"a"},{"isbn":"2","title":"b","pages":9}]`))
	if _, err := dec.ReadToken(); err != nil { // [
		fmt.Println(err)
		return
	}
	enc := jsontext.NewEncoder(os.Stdout, jsontext.Multiline(false))
	for dec.PeekKind() != ']' {
		var b Book
		if err := json.UnmarshalDecode(dec, &b); err != nil {
			fmt.Println(err)
			return
		}
		b.Title = strings.ToUpper(b.Title)
		if err := json.MarshalEncode(enc, b); err != nil { // writes one value, then a newline
			fmt.Println(err)
			return
		}
	}
	_, err := dec.ReadToken() // ]
	_, err2 := dec.ReadToken()
	fmt.Println(err, err2 == io.EOF)
}
201 {"isbn":"1","title":"A"}
422 invalid value at /extra
422 invalid value at /title
400 malformed JSON
400 malformed JSON
400 malformed JSON
413 body too large
{"isbn":"1","title":"A"}
{"isbn":"2","title":"B","pages":9}
<nil> true

The fourth line of the endpoint run is the one to note: a body that holds two JSON values is malformed JSON (400) with no extra code, because UnmarshalRead consumes the reader to EOF. The sixth line is the duplicate-name body, also a syntactic error in v2 that v1 would accept. A string where a string is required is a 422 with the JSON Pointer of the offending value (/title), and an unknown member is a 422 too (/extra).

encoding/json/jsontext

jsontext works on JSON syntax: it never sees Go types. Its two central types are Decoder (reads a stream of tokens and values from an io.Reader) and Encoder (writes them to an io.Writer); both keep a state machine, so writing a mismatched ] or a non-string object name is an error instead of invalid output. A Token is a literal, a string, a number or a delimiter; a Value ([]byte) holds the raw text of one complete value.

Item Purpose

NewDecoder(r, opts...), Decoder.ReadToken, ReadValue, SkipValue, PeekKind

read tokens or whole values, skip what you do not need, look at the next kind without consuming it

Decoder.StackPointer, InputOffset, StackDepth

where you are: the RFC 6901 JSON Pointer of the current position and the byte offset

NewEncoder(w, opts...), Encoder.WriteToken, WriteValue

write tokens or values; jsontext.BeginObject, EndObject, BeginArray, EndArray, Null, True, False and String, Int, Uint, Float, Bool build tokens

Token.Kind, Token.String, Token.Int, Token.Uint, Token.Float

inspect a token; the numeric accessors return (value, error) in 1.27

Value.IsValid, Compact, Indent, Format, Canonicalize, Kind, Clone

operate on raw text; Canonicalize applies RFC 8785 (sorted names, normalized numbers)

Pointer

a JSON Pointer (RFC 6901) with AppendToken, Contains, IsValid, LastToken, Parent and Tokens; carried by errors

AppendQuote, AppendUnquote, AppendFormat, AppendFloat

low-level helpers

SyntacticError, ErrDuplicateName, ErrNonStringName

the error type and sentinels

package main

import (
	"bytes"
	"encoding/json/jsontext"
	"errors"
	"fmt"
	"io"
	"os"
	"strings"
)

const doc = `{"isbn":"9780134190440","authors":[{"name":"Donovan"},{"name":"Kernighan"}],"stock":{"main":3,"annex":0},"price":12.50}`

func main() {
	// 1. Walk the token stream with the JSON Pointer of each token.
	dec := jsontext.NewDecoder(strings.NewReader(doc))
	for {
		tok, err := dec.ReadToken()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			fmt.Println(err)
			return
		}
		fmt.Printf("%-3v %-12v %s\n", tok.Kind(), tok, dec.StackPointer())
	}

	// 2. Skip what you do not need and read one value verbatim.
	dec = jsontext.NewDecoder(strings.NewReader(doc))
	dec.ReadToken() // {
	for dec.PeekKind() != '}' {
		name, _ := dec.ReadToken()
		if name.String() == "stock" {
			v, _ := dec.ReadValue()
			fmt.Println("stock =", v)
			continue
		}
		dec.SkipValue()
	}

	// 3. Encode a stream of tokens; the Encoder validates the grammar.
	var buf bytes.Buffer
	enc := jsontext.NewEncoder(&buf)
	for _, tok := range []jsontext.Token{
		jsontext.BeginObject, jsontext.String("n"), jsontext.Int(3), jsontext.String("ok"), jsontext.True, jsontext.EndObject,
	} {
		if err := enc.WriteToken(tok); err != nil {
			fmt.Println(err)
		}
	}
	fmt.Println(buf.String())
	fmt.Println(enc.WriteToken(jsontext.EndArray))

	// 4. A Value is raw text with helpers: format, compact, canonicalize.
	v := jsontext.Value(`{ "b" : 1.50 , "a" : [ 1 , 2 ] }`)
	fmt.Println(v.IsValid(), v.Kind())
	c := v.Clone()
	c.Compact()
	fmt.Println("compact:   ", c)
	c = v.Clone()
	c.Indent(jsontext.WithIndent("  "))
	fmt.Println("indent:\n" + c.String())
	c = v.Clone()
	c.Canonicalize()
	fmt.Println("canonical: ", c)

	// 5. Duplicate names are a syntax error unless you opt out.
	dup := `{"a":1,"a":2}`
	fmt.Println(jsontext.Value(dup).IsValid(), jsontext.Value(dup).IsValid(jsontext.AllowDuplicateNames(true)))
	_, err := jsontext.NewDecoder(strings.NewReader(dup)).ReadValue()
	var se *jsontext.SyntacticError
	fmt.Println(errors.As(err, &se), errors.Is(err, jsontext.ErrDuplicateName), err)

	// 6. Format straight to a writer.
	enc = jsontext.NewEncoder(os.Stdout, jsontext.Multiline(true), jsontext.WithIndent("\t"))
	enc.WriteValue(jsontext.Value(`{"k":[true,null]}`))
}
{   {
string isbn         /isbn
string 9780134190440 /isbn
string authors      /authors
[   [            /authors
{   {            /authors/0
string name         /authors/0/name
string Donovan      /authors/0/name
}   }            /authors/0
{   {            /authors/1
string name         /authors/1/name
string Kernighan    /authors/1/name
}   }            /authors/1
]   ]            /authors
string stock        /stock
{   {            /stock
string main         /stock/main
number 3            /stock/main
string annex        /stock/annex
number 0            /stock/annex
}   }            /stock
string price        /price
number 12.50        /price
}   }
stock = {"main":3,"annex":0}
{"n":3,"ok":true}

jsontext: mismatching structural token for object or array after offset 18
true {
compact:    {"b":1.50,"a":[1,2]}
indent:
{
  "b": 1.50,
  "a": [
    1,
    2
  ]
}
canonical:  {"a":[1,2],"b":1.5}
false true
true true jsontext: duplicate object member name "a"
{
	"k": [
		true,
		null
	]
}

Highlights: the decoder reports the pointer to every token (/authors/1/name), so you can implement "find the path" tooling without building a tree; PeekKind plus SkipValue allow extracting one member from a large document while skipping the rest; the encoder refused an EndArray token when no array was open (mismatching structural token); and Canonicalize produced {"a":[1,2],"b":1.5}: names sorted and 1.50 normalized to 1.5. That canonical form is the basis of the test helper in Testing JSON.

Migrating from v1 to v2

You do not have to migrate. When you decide to, v1 gives you the tools to do it in small steps, because the v1 package exports the options that reproduce its own behaviour (json.DefaultOptionsV1() and one option per difference), and the v2 functions accept them.

package main

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

type Book struct {
	Title  string        `json:"title"`
	Pages  int           `json:"pages,omitempty"`
	Tags   []string      `json:"tags"`
	Loan   time.Duration `json:"loan"`
	Cover  [3]byte       `json:"cover"`
	Rating *int          `json:"rating,omitempty"`
}

func main() {
	b := Book{Title: "Go", Loan: 24 * time.Hour}

	// 1. Plain v2 defaults reject the Duration.
	_, err := jsonv2.Marshal(b)
	fmt.Println("v2 defaults:     ", err)

	// 2. v1 semantics through the v2 API: identical to jsonv1.Marshal.
	data, err := jsonv2.Marshal(b, jsonv1.DefaultOptionsV1())
	fmt.Println("v2+DefaultV1:    ", string(data), err)
	data, err = jsonv1.Marshal(b)
	fmt.Println("v1 Marshal:      ", string(data), err)

	// 3. Mostly v2, with single v1 behaviours switched back on.
	data, err = jsonv2.Marshal(b, jsonv1.FormatDurationAsNano(true))
	fmt.Println("v2+DurationNano: ", string(data), err)

	// 4. Mostly v1, with single v2 behaviours switched on.
	data, err = jsonv2.Marshal(b,
		jsonv1.DefaultOptionsV1(),
		jsonv2.FormatNilSliceAsNull(false),
		jsonv1.OmitEmptyWithLegacySemantics(false))
	fmt.Println("v1+nilSlice+omit:", string(data), err)

	// 5. DefaultOptionsV2 placed last overrides everything before it.
	_, err = jsonv2.Marshal(b, jsonv1.DefaultOptionsV1(), jsonv2.DefaultOptionsV2())
	fmt.Println("V1 then V2:      ", err)

	// 6. Unmarshal differences: arrays need the exact length, objects are case-sensitive.
	var a [3]bool
	err = jsonv2.Unmarshal([]byte(`[true]`), &a)
	fmt.Println("short array v2:  ", a, err)
	err = jsonv2.Unmarshal([]byte(`[true]`), &a, jsonv1.UnmarshalArrayFromAnyLength(true))
	fmt.Println("short array opt: ", a, err)

	var got Book
	in := []byte(`{"TITLE":"Loud","Tags":["x"]}`)
	err = jsonv2.Unmarshal(in, &got)
	fmt.Printf("case v2:         %+v %v\n", got, err)
	err = jsonv2.Unmarshal(in, &got, jsonv2.MatchCaseInsensitiveNames(true))
	fmt.Printf("case insens.:    %+v %v\n", got, err)

	// 7. Syntax options belong to jsontext.
	var s struct{ A string }
	in = []byte(`{"A":"1","A":"2"}`)
	err = jsonv2.Unmarshal(in, &s)
	fmt.Println("dup v2:          ", s, err)
	err = jsonv2.Unmarshal(in, &s, jsontext.AllowDuplicateNames(true))
	fmt.Println("dup allowed:     ", s, err)
}
v2 defaults:      json: cannot marshal from Go time.Duration within "/loan": no default representation
v2+DefaultV1:     {"title":"Go","tags":null,"loan":86400000000000,"cover":[0,0,0]} <nil>
v1 Marshal:       {"title":"Go","tags":null,"loan":86400000000000,"cover":[0,0,0]} <nil>
v2+DurationNano:  {"title":"Go","pages":0,"tags":[],"loan":86400000000000,"cover":"AAAA"} <nil>
v1+nilSlice+omit: {"title":"Go","pages":0,"tags":[],"loan":86400000000000,"cover":[0,0,0]} <nil>
V1 then V2:       json: cannot marshal from Go time.Duration within "/loan": no default representation
short array v2:   [true false false] json: cannot unmarshal JSON array into Go [3]bool after offset 5: too few array elements
short array opt:  [true false false] <nil>
case v2:         {Title: Pages:0 Tags:[] Loan:0s Cover:[0 0 0] Rating:<nil>} <nil>
case insens.:    {Title:Loud Pages:0 Tags:[x] Loan:0s Cover:[0 0 0] Rating:<nil>} <nil>
dup v2:           {1} jsontext: duplicate object member name "A"
dup allowed:      {2} <nil>

The identity that makes gradual migration possible: json.Marshal(v) is jsonv2.Marshal(v, json.DefaultOptionsV1()), and later options win over earlier ones. The full list of options set by DefaultOptionsV1 is in its documentation; the ones the table above names are CallMethodsWithLegacySemantics, FormatByteArrayAsArray, FormatBytesWithLegacySemantics, FormatDurationAsNano, MatchCaseSensitiveDelimiter, MergeWithLegacySemantics, OmitEmptyWithLegacySemantics, ParseBytesWithLooseRFC4648, ParseTimeWithLooseRFC3339, ReportErrorsWithLegacySemantics, StringifyWithLegacySemantics and UnmarshalArrayFromAnyLength, together with the v2 and jsontext options Deterministic, FormatNilMapAsNull, FormatNilSliceAsNull, MatchCaseInsensitiveNames, AllowDuplicateNames, AllowInvalidUTF8, EscapeForHTML, EscapeForJS and PreserveRawStrings.

A migration recipe, from the official guide:

  1. All at once (simple applications, high risk tolerance): switch the import, run the tests, add a compatibility option where a test fails.

  2. Option by option (complex applications): first replace every call by jsonv2.Marshal(v, json.DefaultOptionsV1()), which is exactly what v1 does and therefore safe; then disable one legacy option at a time (for example jsonv2.FormatNilSliceAsNull(false) placed after DefaultOptionsV1) and run the tests.

  3. Production services: the community wrapper jsonsplit can call both implementations in production, return the v1 result and report the differences (and which option causes each), so the switch is data-driven.

  4. Prefer type-level fixes: change omitempty on numbers, booleans, pointers and interfaces to omitzero, which means the same in both packages; add case:strict or case:ignore to fields so the type decodes identically everywhere; rename or remove duplicate and malformed tags that v1 silently ignored.

The struct tag additions of 1.27 work in v1 when the default (v2-backed) engine is used, which this example proves, and the nojsonv2 run shows that the original engine does not know them:

package main

import (
	"encoding/json"
	"fmt"
)

type Audit struct {
	CreatedBy string `json:"created_by"`
}

type Loan struct {
	Audit  `json:",embed"`
	Book   string `json:"book,case:strict"`
	Member string `json:"member,case:ignore"`
}

func main() {
	l := Loan{Audit{"desk-1"}, "b", "m"}
	data, err := json.Marshal(l)
	fmt.Println(string(data), err)

	var back Loan
	err = json.Unmarshal([]byte(`{"BOOK":"x","MEMBER":"y","CREATED_BY":"z"}`), &back)
	fmt.Printf("%+v %v\n", back, err)
}
$ go run ./e21
{"created_by":"desk-1","book":"b","member":"m"} <nil>
{Audit:{CreatedBy:z} Book: Member:y} <nil>
$ GOEXPERIMENT=nojsonv2 go run ./e21
{"created_by":"desk-1","book":"b","member":"m"} <nil>
{Audit:{CreatedBy:z} Book:x Member:y} <nil>

With the original engine (and with Go 1.26 and earlier) case:strict is an unknown option and ignored, so BOOK still matches. That is a reason not to rely on the new tag options in a library that must work with older toolchains.

What changed between the 1.25 experiment and 1.27

The Go 1.25 blog post and early articles describe the experimental API. Several pieces were removed or renamed before the 1.27 release, so older snippets may not compile or may be rejected at run time. The Go 1.27 release notes list the notable changes:

  • the format tag option, the unknown tag option, the DiscardUnknownMembers option and the SkipFunc sentinel error were removed (the tag json:",unknown" is replaced by the embed fallback for map[string]T and jsontext.Value);

  • the inline tag option was renamed embed;

  • behaviour changed for the string tag option and for MatchCaseInsensitiveNames;

  • the numeric jsontext.Token accessors now also return an error.

The local toolchain agrees: the 1.27.2 source accepts the tag options case, embed, omitzero, omitempty, string and (only to reject it with an explanatory error) format.

Security and robustness

JSON is untrusted input at almost every boundary, and the package documentation’s own security section explains why v2 changed defaults:

  • Duplicate names: two components that read the same document may keep different duplicates (the first or the last). An attacker can exploit that mismatch. v2 rejects duplicates; v1 accepts them. Do not enable AllowDuplicateNames without a reason.

  • Invalid UTF-8 replaced by U+FFFD is silent data corruption; v2 rejects it.

  • Case-insensitive matching means {"Admin":true} can set a field named admin, and may produce duplicates that other parsers read differently; v2 is exact by default. Use MatchCaseInsensitiveNames or case:ignore consciously.

  • Unknown members are ignored by both packages unless you ask: use DisallowUnknownFields (v1) or RejectUnknownMembers(true) (v2) for configuration files and API requests, where a typo should be an error.

  • Large integers lose precision when decoded into any (float64). Use concrete types, json.Number, or quote them.

  • Resource limits: bound the size of untrusted input with http.MaxBytesReader or io.LimitReader, because a decoder reads as much as it is given. The parser also limits nesting depth: in the 1.27.2 toolchain a document nested 10,000 deep is accepted and one level more fails, which protects against stack exhaustion.

package main

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

func main() {
	for _, depth := range []int{9999, 10000, 10001} {
		doc := strings.Repeat("[", depth) + strings.Repeat("]", depth)
		var v any
		err := json.Unmarshal([]byte(doc), &v)
		msg := "ok"
		if err != nil {
			msg = err.Error()
		}
		fmt.Printf("depth %d: %s\n", depth, msg)
	}
}
depth 9999: ok
depth 10000: ok
depth 10001: exceeded max depth

The depth message is engine-dependent (exceeded max depth in v2, invalid character '[' exceeded max depth in the original engine, as the nojsonv2 run shows):

$ GOEXPERIMENT=nojsonv2 go run ./e26
depth 9999: ok
depth 10000: ok
depth 10001: invalid character '[' exceeded max depth
  • HTML and JavaScript embedding: v1 escapes <, >, & and U+2028/2029 by default so that JSON placed in a <script> element is harder to break out of; v2 writes strings minimally, so enable jsontext.EscapeForHTML(true) and jsontext.EscapeForJS(true) when you embed JSON in HTML (the html/template package escapes values in script contexts itself; see Text and HTML Templates).

  • Never build JSON by string concatenation; marshal values. For signing or hashing JSON, canonicalize it first (jsontext.Value.Canonicalize) and see Crypto and Security for the primitives.

Testing JSON

Comparing JSON as strings is brittle: member order, whitespace and number spelling are not semantic. Two reliable approaches exist. Compare decoded values (reflect.DeepEqual or cmp.Equal-style checks on structs), or compare the canonical form of both texts, which jsontext.Value.Canonicalize produces (RFC 8785). The package below keeps the code under test and the tests in separate files, with a table test, a testable Example and a deliberately failing test to show the failure output:

// Package shelfjson shows how to test JSON encoding.
package shelfjson

import (
	"encoding/json"
	"time"
)

// Book is the wire form of a catalogue entry.
type Book struct {
	ISBN      string    `json:"isbn"`
	Title     string    `json:"title"`
	Pages     int       `json:"pages,omitzero"`
	Published time.Time `json:"published,omitzero"`
}

// Encode returns the JSON form of b.
func Encode(b Book) ([]byte, error) { return json.Marshal(b) }
package shelfjson_test

import (
	"encoding/json/jsontext"
	"fmt"
	"testing"
	"time"

	"example.com/shelf/e19/shelfjson"
)

func ExampleEncode() {
	out, _ := shelfjson.Encode(shelfjson.Book{ISBN: "9780134190440", Title: "Go"})
	fmt.Println(string(out))
	// Output: {"isbn":"9780134190440","title":"Go"}
}

// sameJSON compares two documents by meaning: whitespace, member order and
// number spelling do not matter once both are canonicalized (RFC 8785).
func sameJSON(t *testing.T, got, want string) {
	t.Helper()
	g, w := jsontext.Value(got), jsontext.Value(want)
	if err := g.Canonicalize(); err != nil {
		t.Fatalf("got is not valid JSON: %v", err)
	}
	if err := w.Canonicalize(); err != nil {
		t.Fatalf("want is not valid JSON: %v", err)
	}
	if string(g) != string(w) {
		t.Errorf("JSON differs\n got: %s\nwant: %s", g, w)
	}
}

func TestEncode(t *testing.T) {
	tests := []struct {
		name string
		in   shelfjson.Book
		want string
	}{
		{"minimal", shelfjson.Book{ISBN: "1", Title: "A"}, `{ "title": "A", "isbn": "1" }`},
		{"pages", shelfjson.Book{ISBN: "1", Title: "A", Pages: 380}, `{"isbn":"1","title":"A","pages":3.8e2}`},
		{"published", shelfjson.Book{ISBN: "1", Title: "A", Published: time.Date(2015, 11, 16, 0, 0, 0, 0, time.UTC)},
			`{"isbn":"1","title":"A","published":"2015-11-16T00:00:00Z"}`},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			out, err := shelfjson.Encode(tt.in)
			if err != nil {
				t.Fatal(err)
			}
			sameJSON(t, string(out), tt.want)
		})
	}
}

func TestEncodeDetectsRegression(t *testing.T) {
	out, _ := shelfjson.Encode(shelfjson.Book{ISBN: "1", Title: "A"})
	sameJSON(t, string(out), `{"isbn":"1","title":"B"}`)
}
$ go test -v -run 'TestEncode$|Example' ./...
=== RUN   TestEncode
=== RUN   TestEncode/minimal
=== RUN   TestEncode/pages
=== RUN   TestEncode/published
--- PASS: TestEncode (0.00s)
    --- PASS: TestEncode/minimal (0.00s)
    --- PASS: TestEncode/pages (0.00s)
    --- PASS: TestEncode/published (0.00s)
=== RUN   ExampleEncode
--- PASS: ExampleEncode (0.00s)
PASS
ok  	example.com/shelf/e19/shelfjson	0.064s
$ go test -run Regression ./...
--- FAIL: TestEncodeDetectsRegression (0.00s)
    book_test.go:58: JSON differs
         got: {"isbn":"1","title":"A"}
        want: {"isbn":"1","title":"B"}
FAIL
FAIL	example.com/shelf/e19/shelfjson	0.069s
FAIL

Other practical rules: use Deterministic(true) (v2) or rely on v1’s sorted map keys when you compare text; keep golden files in testdata/ (see Testing); write fuzz targets that decode arbitrary bytes and require that a successful decode re-encodes to an equal value (see Benchmarks, Fuzzing and synctest); and do not assert on error text of the JSON package, which the release notes say can change between engines and releases. Assert on the error type (errors.As with *json.SyntaxError, *json.UnmarshalTypeError, *json.SemanticError) or on errors.Is with a sentinel.

Other encodings in brief

encoding/json is one of several encoding/... packages. The common contract is a pair of functions or interfaces: encoding.TextMarshaler/TextUnmarshaler and encoding.BinaryMarshaler/BinaryUnmarshaler are what json, xml, flag.TextVar and gob use to let a type choose its own representation.

Package Format Use it for

encoding/xml

XML with struct tags (xml:"name,attr", xml:"a>b", ,chardata, ,omitempty)

SOAP-era services, feeds (Atom/RSS), office formats; Decoder.Token streams large documents

encoding/csv

RFC 4180 CSV

importing and exporting tabular data; Reader.ReadAll, Reader.Read, Writer with Flush

encoding/gob

Go-specific binary, self-describing, type-checked

Go-to-Go streams (RPC, caches); not interoperable and not for untrusted input

encoding/binary

fixed-size values and varints with an explicit byte order

network protocols and file headers; Read, Write, ByteOrder, AppendUvarint

encoding/base64, encoding/base32, encoding/hex

binary data as text

tokens, hashes, embedding bytes in JSON or URLs (StdEncoding, URLEncoding, RawURLEncoding)

encoding/pem, encoding/asn1

certificates and keys

see Crypto and Security

package main

import (
	"bytes"
	"encoding/base64"
	"encoding/binary"
	"encoding/csv"
	"encoding/gob"
	"encoding/hex"
	"encoding/xml"
	"fmt"
	"os"
	"strings"
)

type Book struct {
	XMLName xml.Name `xml:"book"`
	ISBN    string   `xml:"isbn,attr"`
	Title   string   `xml:"title"`
	Authors []string `xml:"authors>author"`
	Draft   string   `xml:"draft,omitempty"`
}

func main() {
	b := Book{ISBN: "9780134190440", Title: "The Go Programming Language", Authors: []string{"Donovan", "Kernighan"}}

	// encoding/xml
	out, err := xml.MarshalIndent(b, "", "  ")
	fmt.Println(string(out), err)
	var xb Book
	err = xml.Unmarshal(out, &xb)
	fmt.Println(xb.ISBN, xb.Authors, err)

	// encoding/csv
	r := csv.NewReader(strings.NewReader("isbn,title\n9780134190440,\"Go, the book\"\n"))
	rows, err := r.ReadAll()
	fmt.Printf("%q %v\n", rows, err)
	w := csv.NewWriter(os.Stdout)
	w.Write([]string{"9780134190440", `say "hi", then go`})
	w.Flush()
	fmt.Println(w.Error())

	// encoding/gob: Go-only, self-describing, for Go-to-Go streams.
	var buf bytes.Buffer
	err = gob.NewEncoder(&buf).Encode(map[string][]int{"a": {1, 2}})
	var gm map[string][]int
	err2 := gob.NewDecoder(&buf).Decode(&gm)
	fmt.Println(gm, err, err2)

	// encoding/binary: fixed-size values and varints.
	hdr := struct {
		Magic   [4]byte
		Version uint16
		Count   uint32
	}{[4]byte{'S', 'H', 'L', 'F'}, 1, 300}
	var bb bytes.Buffer
	err = binary.Write(&bb, binary.BigEndian, hdr)
	fmt.Printf("% x %v\n", bb.Bytes(), err)
	fmt.Println(binary.BigEndian.Uint32(bb.Bytes()[6:]), binary.AppendUvarint(nil, 300))

	// encoding/base64 and encoding/hex
	s := base64.StdEncoding.EncodeToString([]byte("shelf?>"))
	fmt.Println(s, base64.URLEncoding.EncodeToString([]byte("shelf?>")), base64.RawURLEncoding.EncodeToString([]byte("shelf?>")))
	raw, err := base64.StdEncoding.DecodeString("c2hlbGY/Pg==")
	fmt.Printf("%s %v\n", raw, err)
	fmt.Println(hex.EncodeToString([]byte("go")), strings.TrimSpace(hex.Dump([]byte("shelf"))))
}
<book isbn="9780134190440">
  <title>The Go Programming Language</title>
  <authors>
    <author>Donovan</author>
    <author>Kernighan</author>
  </authors>
</book> <nil>
9780134190440 [Donovan Kernighan] <nil>
[["isbn" "title"] ["9780134190440" "Go, the book"]] <nil>
9780134190440,"say ""hi"", then go"
<nil>
map[a:[1 2]] <nil> <nil>
53 48 4c 46 00 01 00 00 01 2c <nil>
300 [172 2]
c2hlbGY/Pg== c2hlbGY_Pg== c2hlbGY_Pg
shelf?> <nil>
676f 00000000  73 68 65 6c 66                                    |shelf|

Observations: xml honours the tags (xml:"authors>author" nests, isbn,attr makes an attribute, omitempty skipped draft); csv quotes only when needed (a field with a comma or a double quote); gob needs both sides to be Go and should never decode untrusted data; binary.Write lays out a fixed-size struct byte by byte (53 48 4c 46 is SHLF, then a 16-bit and a 32-bit big-endian number: 00 01 and 00 00 01 2c, which is 300), while AppendUvarint shows the variable-length encoding (172 2) and base64 shows the URL alphabet and the unpadded form of the same bytes.

Pitfalls

Pitfall What to do

Unexported field with a tag, or a lower-case field name

Fields must be exported to be encoded; v2 rejects a tag on an unexported field

json.Unmarshal(data, v) with a non-pointer

Pass &v; vet reports it and the call fails with InvalidUnmarshalError

Numbers decoded into any are float64

Decode into structs, use Decoder.UseNumber, or quote large integers with ,string

omitempty does not omit a zero time.Time or a zero struct

Use omitzero

omitempty on an int or bool in v2 writes zero values

Use omitzero (same meaning in v1 and v2)

Nil slice is null in v1 and [] in v2

Decide explicitly; initialise slices you mean to send as [] and test the output

Field names matched case-insensitively in v1

Set case:strict on fields (works on 1.27) or migrate to v2

Pointer-receiver MarshalJSON silently ignored in v1

Use a value receiver for marshal methods; v2 always calls it

Calling json.Marshal(x) on the receiver inside MarshalJSON

Infinite recursion; convert to a defined type without the methods first

Reading a stream with Unmarshal per line is fine, with UnmarshalRead is not

UnmarshalRead requires exactly one value; use a Decoder or jsontext.Decoder for streams

Ignoring trailing data after Decoder.Decode

Call Decode again and require io.EOF for single-value bodies

Using the bytes returned alongside an error

Check err first; the output is not meaningful on failure

Enum as int on the wire

Implement MarshalText/UnmarshalText so the wire form is a stable string and works as a map key

Unbounded request bodies

http.MaxBytesReader; the decoder reads whatever it is given

Assertions on JSON error text

Match error types and sentinels; the text differs between v1/v2 engines and releases

Building with GOEXPERIMENT=nojsonv2 and importing v2

Not possible; the opt-out is temporary and expected to be removed

Where the books are outdated

  • Bodner, Learning Go (1st edition), chapter 11 teaches encoding/json through tags, omitempty, Marshal and Unmarshal, the Encoder and Decoder, and custom marshalers, and that core is still correct for v1. It predates omitzero (Go 1.24), the new(expr) form for optional fields (1.26), the stricter-defaults encoding/json/v2 and jsontext packages (generally available in 1.27) and the fact that v1 is now implemented on v2, so error text and a few edge cases changed. Use any where older text writes interface{}, and io.ReadAll/os.ReadFile where older text uses ioutil.

  • Vivien, Learning Go Programming (2016), chapter 10 shows JSON tags and custom marshalers and the encoding/gob package. Both remain valid, but the book targets Go 1.6: no omitzero, no json.Number discussion of precision, no v2, and the io/ioutil helpers it uses are deprecated in favour of io and os.

  • Tutorials from the Go 1.25 experiment period describe an API that was later changed (format, unknown and inline options and SkipFunc); follow the package documentation of the toolchain you use.

  • Older articles that say "never use json for non-trivial types because it cannot do X" often predate omitzero, TextMarshaler map keys and the v2 per-call marshalers.

Version notes

  • Go 1.0 to 1.23: v1 only; omitempty, string, Marshaler, TextMarshaler, Encoder/Decoder, RawMessage, Number, DisallowUnknownFields (1.10) and UseNumber.

  • Go 1.24: omitzero.

  • Go 1.25: GOEXPERIMENT=jsonv2 provides experimental encoding/json/v2 and encoding/json/jsontext.

  • Go 1.26: no JSON package change; new(expr) can populate pointer fields.

  • Go 1.27: encoding/json/v2 and encoding/json/jsontext are generally available; encoding/json is backed by v2 and gains the options (DefaultOptionsV1 and the *WithLegacySemantics family); tag options case:ignore, case:strict and embed; GOEXPERIMENT=nojsonv2 restores the original engine temporarily.

  • Go 1.28 is not released at the time of writing (expected February 2027); the opt-out is documented as expected to be removed in a future release.