How Go's JSON Parser Avoids Reflection: The Scanner and the v2 Experiment

encoding/json splits parsing into a byte-at-a-time state machine and a reflection-driven decoder, and the Go 1.28 tree now carries an experimental jsontext plus encoding/json/v2 rewrite behind the goexperiment.jsonv2 build tag.

TL;DR: encoding/json is two programs wearing one coat. A hand-written scanner — a state machine with one indirect call per byte — validates the syntax and reports events, and a reflection-driven decoder walks the result into your Go values. The Go 1.28 tree also contains a full experimental rewrite: encoding/json/jsontext for syntax and encoding/json/v2 for semantics, both gated behind GOEXPERIMENT=jsonv2.

JSON is the most-executed format on the modern internet, and Go’s standard library JSON implementation is older than most people think. encoding/json dates from 2009, when the standard library’s flagship data interchange format was written by a small team for a small language, and it has been bolted onto ever since.

But there is a lot to learn from the inside of it: the scanner is a hand-written state machine that most Go programmers have never read, the decoder is a reflection walk with a surprising amount of caching, and the Go 1.28 development tree now contains an experimental replacement that splits “JSON syntax” from “JSON semantics” — a split that is arguably the most important design decision in the whole package.

This is a walk through how it all works, based on reading the Go source tree (Go 1.28 development tree).

Two passes over your data

json.Unmarshal does something that surprises people: it parses the input twice.

go
// src/encoding/json/decode.go:108
func Unmarshal(data []byte, v any) error {
	// Check for well-formedness.
	// Avoids filling out half a data structure
	// before discovering a JSON syntax error.
	var d decodeState
	err := checkValid(data, &d.scan)
	if err != nil {
		return err
	}

	d.init(data)
	return d.unmarshal(v)
}

The first pass is checkValid, which runs the scanner over every byte and throws the result away. The second pass is the actual decode, which runs the scanner again, this time reacting to the events it reports.

Why? The comment says it: “Avoids filling out half a data structure before discovering a JSON syntax error.” Without it, json.Unmarshal into a struct could fill in the first ten fields, hit a syntax error at byte 5000, and leave you with a half-populated struct. With it, the struct is either fully populated or untouched.

The cost is one extra linear pass. For a 1 MB document that is measurable but small compared to the reflection work in the second pass.

text
   data []byte
       |
       v
   +----------------------------+
   | PASS 1: checkValid        |
   | scanner.step() per byte   |  --> error? return SyntaxError
   | result: syntax OK or not  |
   +----------------------------+
       |
       | (if OK)
       v
   +----------------------------+
   | PASS 2: d.unmarshal(v)     |
   | scanner reports events:   |
   |   scanBeginObject  -> d.object(reflect.Value)
   |   scanBeginArray   -> d.array(reflect.Value)
   |   scanBeginLiteral -> d.literalStore(bytes, reflect.Value)
   +----------------------------+
       |
       v
   populated Go value

The v2 experiment replaces the two-layer arrangement with a three-layer one:

text
   +---------------------------------------------+
   | encoding/json/jsontext   (SYNTAX)           |
   |   Encoder / Decoder, Token, Value, Kind      |
   |   state machine, duplicate-name namespaces  |
   +---------------------------------------------+
                    |
                    v
   +---------------------------------------------+
   | encoding/json/v2         (SEMANTICS)        |
   |   Marshal / Unmarshal / MarshalWrite / ...  |
   |   per-type arshalers, struct field cache    |
   +---------------------------------------------+

The scanner: a state machine with an indirect call per byte

The scanner is the heart of the package, and it opens with an honest assessment of itself:

go
// src/encoding/json/scanner.go:9
// JSON value parser state machine.
// Just about at the limit of what is reasonable to write by hand.
// Some parts are a bit tedious, but overall it nicely factors out the
// otherwise common code from the multiple scanning functions
// in this package (Compact, Indent, checkValid, etc).

The “multiple scanning functions” it factors out are json.Valid, json.Compact, json.Indent, and the streaming json.Decoder — all of them need to know where the tokens are, and none of them want to write their own JSON grammar.

The state is a function pointer, and the comment explains why:

go
// src/encoding/json/scanner.go:68
type scanner struct {
	// The step is a func to be called to execute the next transition.
	// Also tried using an integer constant and a single func
	// with a switch, but using the func directly was 10% faster
	// on a 64-bit Mac Mini, and it's nicer to read.
	step func(*scanner, byte) int

	// Reached end of top-level value.
	endTop bool

	// Stack of what we're in the middle of - array values, object keys, object values.
	parseState []int

	// Error that happened, if any.
	err error

	// total bytes consumed, updated by decoder.Decode (and deliberately
	// not set to zero by scan.reset)
	bytes int64
}

An indirect call per input byte. That sounds terrible, and it is — but the alternative (a switch on an integer state constant, in one big function) was measured as 10% slower, because the big switch has a mispredicted branch on every byte while the indirect call is a perfectly predictable one, and the states have different code sizes.

The scanner reports events rather than values. The opcodes are:

go
// src/encoding/json/scanner.go:111
// These values are returned by the state transition functions
// assigned to scanner.state and the method scanner.eof.
// They give details about the current state of the scan that
// callers might be interested to know about.
// It is okay to ignore the return value of any particular
// call to scanner.state: if one call returns scanError,
// every subsequent call will return scanError too.
const (
	// Continue.
	scanContinue     = iota // uninteresting byte
	scanBeginLiteral        // end implied by next result != scanContinue
	scanBeginObject         // begin object
	scanObjectKey           // just finished object key (string)
	scanObjectValue         // just finished non-last object value
	scanEndObject           // end object (implies scanObjectValue if possible)
	scanBeginArray          // begin array
	scanArrayValue          // just finished array value
	scanEndArray            // end array (implies scanArrayValue if possible)
	scanSkipSpace           // space byte; can skip; known to be last "continue" result

	// Stop.
	scanEnd   // top-level value ended *before* this byte; known to be first "stop" result
	scanError // hit an error, scanner.err.
)

So the scanner is not a parser in the usual sense. It is a validator that emits events, and the decoder is written as a state machine over those events rather than over bytes. That is the key architectural decision, and it is what lets the same scanner serve Compact, Indent, Valid, the Decoder, and the Unmarshal path.

The grammar itself is the states. Starting a value:

go
// src/encoding/json/scanner.go:216
// stateBeginValue is the state at the beginning of the input.
func stateBeginValue(s *scanner, c byte) int {
	if isSpace(c) {
		return scanSkipSpace
	}
	switch c {
	case '{':
		s.step = stateBeginStringOrEmpty
		return s.pushParseState(c, parseObjectKey, scanBeginObject)
	case '[':
		s.step = stateBeginValueOrEmpty
		return s.pushParseState(c, parseArrayValue, scanBeginArray)
	case '"':
		s.step = stateInString
		return scanBeginLiteral
	case '-':
		s.step = stateNeg
		return scanBeginLiteral
	case '0': // beginning of 0.123
		s.step = state0
		return scanBeginLiteral
	case 't': // beginning of true
		s.step = stateT
		return scanBeginLiteral
	case 'f': // beginning of false
		s.step = stateF
		return scanBeginLiteral
	case 'n': // beginning of null
		s.step = stateN
		return scanBeginLiteral
	}
	if '1' <= c && c <= '9' { // beginning of 1234.5
		s.step = state1
		return scanBeginLiteral
	}
	return s.error(c, "looking for beginning of value")
}

and finishing one, where the parseState stack decides what is legal next:

go
// src/encoding/json/scanner.go:279
// stateEndValue is the state after completing a value,
// such as after reading `{}` or `true` or `["x"`.
func stateEndValue(s *scanner, c byte) int {
	n := len(s.parseState)
	if n == 0 {
		// Completed top-level before the current byte.
		s.step = stateEndTop
		s.endTop = true
		return stateEndTop(s, c)
	}
	if isSpace(c) {
		s.step = stateEndValue
		return scanSkipSpace
	}
	ps := s.parseState[n-1]
	switch ps {
	case parseObjectKey:
		if c == ':' {
			s.parseState[n-1] = parseObjectValue
			s.step = stateBeginValue
			return scanObjectKey
		}
		return s.error(c, "after object key")
	case parseObjectValue:
		if c == ',' {
			s.parseState[n-1] = parseObjectKey
			s.step = stateBeginString
			return scanObjectValue
		}
		if c == '}' {
			s.popParseState()
			return scanEndObject
		}
		return s.error(c, "after object key:value pair")
	case parseArrayValue:
		if c == ',' {
			s.step = stateBeginValue
			return scanArrayValue
		}
		if c == ']' {
			s.popParseState()
			return scanEndArray
		}
		return s.error(c, "after array element")
	}
	return s.error(c, "")
}

Three parse states — “expecting an object key”, “expecting an object value”, “expecting an array value” — pushed and popped on a stack of ints. And a depth limit, because JSON nesting is a denial-of-service vector:

go
// src/encoding/json/scanner.go:146
// This limits the max nesting depth to prevent stack overflow.
// This is permitted by https://tools.ietf.org/html/rfc7159#section-9
const maxNestingDepth = 10000

Ten thousand nested arrays is deep enough to blow the stack of a recursive decoder and shallow enough to permit any legitimate document.

The decoder: a reflection walk

The scanner produced events. The decoder consumes them. decodeState is the whole decoder state:

go
// src/encoding/json/decode.go:219
// decodeState represents the state while decoding a JSON value.
type decodeState struct {
	data                  []byte
	off                   int // next read offset in data
	opcode                int // last read result
	scan                  scanner
	errorContext          *errorContext
	savedError            error
	useNumber             bool
	disallowUnknownFields bool
}

and value is the dispatcher that turns the scanner’s events into recursive calls:

go
// src/encoding/json/decode.go:370
func (d *decodeState) value(v reflect.Value) error {
	switch d.opcode {
	default:
		panic(phasePanicMsg)

	case scanBeginArray:
		if v.IsValid() {
			if err := d.array(v); err != nil {
				return err
			}
		} else {
			d.skip()
		}
		d.scanNext()

	case scanBeginObject:
		if v.IsValid() {
			if err := d.object(v); err != nil {
				return err
			}
		} else {
			d.skip()
		}
		d.scanNext()

	case scanBeginLiteral:
		// All bytes inside literal return scanContinue op code.
		start := d.readIndex()
		d.rescanLiteral()

		if v.IsValid() {
			if err := d.literalStore(d.data[start:d.readIndex()], v, false); err != nil {
				return err
			}
		}
	}
	return nil
}

The v.IsValid() check is the “skip this subtree” mechanism: when the decoder is asked for a value it does not want — an unknown struct field, a value it is going to discard — it passes an invalid reflect.Value and the decoder skips over the whole subtree without allocating anything for it.

literalStore is where a scalar lands in a Go value, and it is a switch on the first byte of the literal:

go
// src/encoding/json/decode.go:863
func (d *decodeState) literalStore(item []byte, v reflect.Value, fromQuoted bool) error {
	// Check for unmarshaler.
	if len(item) == 0 {
		// Empty string given.
		d.saveError(fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type()))
		return nil
	}
	isNull := item[0] == 'n' // null
	u, ut, pv := indirect(v, isNull)
	if u != nil {
		return u.UnmarshalJSON(item)
	}
go
// src/encoding/json/decode.go:941
	case '"': // string
		s, ok := unquoteBytes(item)
		if !ok {
			if fromQuoted {
				return fmt.Errorf("json: invalid use of ,string struct tag, trying to unmarshal %q into %v", item, v.Type())
			}
			panic(phasePanicMsg)
		}
		switch v.Kind() {
		default:
			d.saveError(&UnmarshalTypeError{Value: "string", Type: v.Type(), Offset: int64(d.readIndex())})
		case reflect.Slice:
			if v.Type().Elem().Kind() != reflect.Uint8 {
				d.saveError(&UnmarshalTypeError{Value: "string", Type: v.Type(), Offset: int64(d.readIndex())})
				break
			}
			b := make([]byte, base64.StdEncoding.DecodedLen(len(s)))
			n, err := base64.StdEncoding.Decode(b, s)
			if err != nil {
				d.saveError(err)
				break
			}
			v.SetBytes(b[:n])
		case reflect.String:
			t := string(s)
			if v.Type() == numberType && !isValidNumber(t) {
				return fmt.Errorf("json: invalid number literal, trying to unmarshal %q into Number", item)
			}
			v.SetString(t)
		case reflect.Interface:
			if v.NumMethod() == 0 {
				v.Set(reflect.ValueOf(string(s)))
			} else {
				d.saveError(&UnmarshalTypeError{Value: "string", Type: v.Type(), Offset: int64(d.readIndex())})
			}
		}

So a JSON string can land in a string, in a []byte (base64-decoded), or in an any. That is three completely different behaviours decided by v.Kind(), at run time, per value.

Where the reflection cost is controlled

The expensive part of reflection is not SetString — it is figuring out what to do. Doing that on every value of every document would be unbearable. So Go caches the answer per type.

For the encoder:

go
// src/encoding/json/encode.go:373
var encoderCache sync.Map // map[reflect.Type]encoderFunc

func valueEncoder(v reflect.Value) encoderFunc {
	if !v.IsValid() {
		return invalidValueEncoder
	}
	return typeEncoder(v.Type())
}

typeEncoder builds a closure specialised for the type and stores it in a sync.Map. The recursion dance with sync.OnceValue exists to handle recursive types:

go
// src/encoding/json/encode.go:382
func typeEncoder(t reflect.Type) encoderFunc {
	if fi, ok := encoderCache.Load(t); ok {
		return fi.(encoderFunc)
	}

	// To deal with recursive types, populate the map with an
	// indirect func before we build it. If the type is recursive,
	// the second lookup for the type will return the indirect func.
	//
	// This indirect func is only used for recursive types,
	// and briefly during racing calls to typeEncoder.
	indirect := sync.OnceValue(func() encoderFunc {
		return newTypeEncoder(t, true)
	})

For the decoder, the equivalent cache is over the struct fields:

go
// src/encoding/json/encode.go:705
type structFields struct {
	list         []field
	byExactName  map[string]*field
	byFoldedName map[string]*field
}
go
// src/encoding/json/encode.go:1323
var fieldCache sync.Map // map[reflect.Type]structFields

// cachedTypeFields is like typeFields but uses a cache to avoid repeated work.
func cachedTypeFields(t reflect.Type) structFields {
	if f, ok := fieldCache.Load(t); ok {
		return f.(structFields)
	}
	f, _ := fieldCache.LoadOrStore(t, typeFields(t))
	return f.(structFields)
}

and the lookup at decode time is two map hits:

go
// src/encoding/json/decode.go:705
		} else {
			f := fields.byExactName[string(key)]
			if f == nil {
				f = fields.byFoldedName[string(foldName(key))]
			}
			if f != nil {
				subv = v
				destring = f.quoted

First try the exact name, then the case-folded name. That second lookup is why encoding/json matches "Name" to a field named name — a convenience that is also a genuine interoperability hazard, and one of the things the v2 experiment changes.

The encoder side does the same field walk, once per field, following an index path:

go
// src/encoding/json/encode.go:711
func (se structEncoder) encode(e *encodeState, v reflect.Value, opts encOpts) {
	next := byte('{')
FieldLoop:
	for i := range se.fields.list {
		f := &se.fields.list[i]

		// Find the nested struct field by following f.index.
		fv := v
		for _, i := range f.index {
			if fv.Kind() == reflect.Pointer {
				if fv.IsNil() {
					continue FieldLoop
				}
				fv = fv.Elem()
			}
			fv = fv.Field(i)
		}

f.index is the path through embedded structs, precomputed once at compile-of-the-type time.

The v2 experiment

The Go 1.28 development tree contains something the 1.x releases never had: a complete second implementation of encoding/json, guarded by a build tag.

The gate is a Go experiment:

go
// src/internal/goexperiment/exp_jsonv2_on.go:1
// Code generated by mkconsts.go. DO NOT EDIT.

//go:build goexperiment.jsonv2

package goexperiment

const JSONv2 = true
const JSONv2Int = 1

Every existing v1 file is excluded when the experiment is on:

go
// src/encoding/json/scanner.go:5
//go:build !goexperiment.jsonv2

and replaced by a v2_-prefixed file in the same package, so that json.Marshal and friends keep their old signatures and semantics while being implemented on top of the new machinery:

go
// src/encoding/json/v2_decode.go:5
//go:build goexperiment.jsonv2

The new code lives in two packages.

encoding/json/jsontext: syntax only

The jsontext package’s doc comment makes the architectural argument better than any blog post could:

go
// src/encoding/json/jsontext/doc.go:50
// # Terminology
//
// The terms "encode" and "decode" are used for syntactic functionality
// that is concerned with processing JSON based on its grammar, and
// the terms "marshal" and "unmarshal" are used for semantic functionality
// that determines the meaning of JSON values as Go values and vice versa.
// This package deals with JSON syntax,
// while [encoding/json/v2] deals with JSON semantics.
// The goal is to provide a clear distinction between functionality that
// is purely concerned with encoding versus that of marshaling.
// For example, one can directly encode a stream of JSON tokens without
// needing to marshal a concrete Go value representing them.
// Similarly, one can decode a stream of JSON tokens without
// needing to unmarshal them into a concrete Go value.

Syntax and semantics are separated. jsontext knows what a JSON object is; it does not know what a Go struct is. encoding/json/v2 knows what a Go struct is; it does not re-implement the grammar.

The state machine in jsontext is a different design from v1’s. Where v1 used a stack of ints and one function per state, jsontext packs the entire per-level state into a single 64-bit word:

go
// src/encoding/json/jsontext/state.go:430
// stateEntry encodes several artifacts within a single unsigned integer:
//   - whether this represents a JSON object or array,
//   - whether this object should check for duplicate names, and
//   - how many elements are in this JSON object or array.
type stateEntry uint64

const (
	// The type mask (1 bit) records whether this is a JSON object or array.
	stateTypeMask   stateEntry = 0x8000_0000_0000_0000
	stateTypeObject stateEntry = 0x8000_0000_0000_0000
	stateTypeArray  stateEntry = 0x0000_0000_0000_0000

	// The name check mask (2 bits) records whether to update
	// the namespaces for the current JSON object and
	// whether the namespace is valid.
	stateNamespaceMask    stateEntry = 0x6000_0000_0000_0000
	stateDisableNamespace stateEntry = 0x4000_0000_0000_0000
	stateInvalidNamespace stateEntry = 0x2000_0000_0000_0000

	// The count mask (61 bits) records the number of elements.
	stateCountMask    stateEntry = 0x1fff_ffff_ffff_ffff
	stateCountLSBMask stateEntry = 0x0000_0000_0000_0001
	stateCountOdd     stateEntry = 0x0000_0000_0000_0001
	stateCountEven    stateEntry = 0x0000_0000_0000_0000
)

One bit for object-or-array, two bits for the duplicate-name namespace state, sixty-one bits for the element count. And here is the lovely part: the low bit of the count doubles as the “am I expecting a name or a value?” flag, so the parser does not need a separate bit for it.

go
// src/encoding/json/jsontext/state.go:472
// NeedObjectName reports whether the next token must be a JSON string,
// which is necessary for JSON object names.
func (e stateEntry) NeedObjectName() bool {
	return e&(stateTypeMask|stateCountLSBMask) == stateTypeObject|stateCountEven
}

// needImplicitColon reports whether a colon should occur next,
// which always occurs after JSON object names.
func (e stateEntry) needImplicitColon() bool {
	return e.needObjectValue()
}

// needObjectValue reports whether the next token must be a JSON value,
// which is necessary after every JSON object name.
func (e stateEntry) needObjectValue() bool {
	return e&(stateTypeMask|stateCountLSBMask) == stateTypeObject|stateCountOdd
}

“Expecting a name” and “expecting a value” are the same bit with different polarity, so the parity of the element count is the parser state. That is the kind of trick that only shows up once you decide to make the state machine word-sized.

The tokenizer itself is a recursive descent over a byte buffer, and it dispatches on the first byte of the value, which is enough to identify the kind:

go
// src/encoding/json/jsontext/decode.go:861
func (d *decoderState) consumeValue(flags *jsonwire.ValueFlags, pos, depth int) (newPos int, err error) {
	for {
		var n int
		var err error
		switch next := Kind(d.buf[pos]).normalize(); next {
		case 'n':
			if n = jsonwire.ConsumeNull(d.buf[pos:]); n == 0 {
				n, err = jsonwire.ConsumeLiteral(d.buf[pos:], "null")
			}
		case 'f':
			if n = jsonwire.ConsumeFalse(d.buf[pos:]); n == 0 {
				n, err = jsonwire.ConsumeLiteral(d.buf[pos:], "false")
			}
		case 't':
			if n = jsonwire.ConsumeTrue(d.buf[pos:]); n == 0 {
				n, err = jsonwire.ConsumeLiteral(d.buf[pos:], "true")
			}
		case '"':
			if n = jsonwire.ConsumeSimpleString(d.buf[pos:]); n == 0 {
				return d.consumeString(flags, pos)
			}
		case '0':
			// NOTE: Since JSON numbers are not self-terminating,
			// we need to make sure that the next byte is not part of a number.
			if n = jsonwire.ConsumeSimpleNumber(d.buf[pos:]); n == 0 || d.needMore(pos+n) {
				return d.consumeNumber(pos)
			}
		case '{':
			return d.consumeObject(flags, pos, depth)
		case '[':
			return d.consumeArray(flags, pos, depth)

And the token kinds are just bytes:

go
// src/encoding/json/jsontext/token.go:620
// A Kind represents the kind of a JSON token.
//
// A Kind is a single byte, which is conveniently the first byte of that
// kind's symbol in the grammar (except for numbers, which are always represented
// with '0').
type Kind byte

const (
	KindInvalid     Kind = 0   // invalid kind
	KindNull        Kind = 'n' // null
	KindFalse       Kind = 'f' // false
	KindTrue        Kind = 't' // true
	KindString      Kind = '"' // string
	KindNumber      Kind = '0' // number
	KindBeginObject Kind = '{' // begin object
	KindEndObject   Kind = '}' // end object
	KindBeginArray  Kind = '[' // begin array
	KindEndArray    Kind = ']' // end array
)

encoding/json/v2: semantics, and streaming

The v2 package offers the same three shapes of operation as the old json.Encoder/json.Decoder, but as plain functions with a consistent naming scheme — and crucially, without the []byte round trip:

go
// src/encoding/json/v2/arshal.go:175
// MarshalWrite serializes a Go value into an [io.Writer] according to the provided
// marshal and encode options (while ignoring unmarshal or decode options).
// It does not terminate the output with a newline.
// See [Marshal] for details about the conversion of a Go value into JSON.
func MarshalWrite(out io.Writer, in any, opts ...Options) (err error) {
	enc := export.GetStreamingEncoder(out, opts...)
	defer export.PutStreamingEncoder(enc)
	xe := export.Encoder(enc)
	xe.Flags.Set(jsonflags.OmitTopLevelNewline | 1)
	err = marshalEncode(enc, in, &xe.Struct)
	if err != nil && xe.Flags.Get(jsonflags.ReportErrorsWithLegacySemantics) {
		return internal.TransformMarshalError(in, err)
	}
	return err
}
go
// src/encoding/json/v2/arshal.go:191
// MarshalEncode serializes a Go value into an [jsontext.Encoder] according to
// the provided marshal or encode options (while ignoring unmarshal or decode options).
// The options provided take precedence over options already applied on
// the [jsontext.Encoder] and only apply for the duration of the marshal call.
//
// See [Marshal] for details about the conversion of a Go value into JSON.
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) (err error) {
go
// src/encoding/json/v2/arshal.go:397
// UnmarshalRead deserializes a Go value from an [io.Reader] according to the
// provided unmarshal and decode options (while ignoring marshal or encode options).
// The input must be a single JSON value with optional whitespace interspersed.
// It consumes the entirety of [io.Reader] until [io.EOF] is encountered,
// without reporting an error for EOF. The output must be a non-nil pointer.
// See [Unmarshal] for details about the conversion of JSON into a Go value.
func UnmarshalRead(in io.Reader, out any, opts ...Options) (err error) {

So the v2 API is: Marshal (into []byte), MarshalWrite (into an io.Writer), MarshalEncode (into a jsontext.Encoder), and the mirror Unmarshal, UnmarshalRead, UnmarshalDecode. The io.Writer and io.Reader variants never materialise the whole JSON document in memory — that is the performance story, and it is a real one for large payloads.

The per-type work is still cached, with a sync.Once so that concurrent first-use of a type does not race:

go
// src/encoding/json/v2/fields.go:80
type structFieldsCacheEntry struct {
	once   sync.Once
	fields structFields
	err    *SemanticError
}

var structFieldsCache sync.Map // map[reflect.Type]*structFieldsCacheEntry

func lookupStructFields(t reflect.Type) (*structFields, *SemanticError) {
	entry, _ := structFieldsCache.LoadOrStore(t, new(structFieldsCacheEntry))
	cached := entry.(*structFieldsCacheEntry)
	cached.once.Do(func() {
		cached.fields, cached.err = makeStructFields(t)
	})
	return &cached.fields, cached.err
}

The behaviour changes

The v2 doc is unusually candid about where the two packages disagree, and it is worth reading in full. The summary:

  • Invalid UTF-8: v1 replaces it with U+FFFD; v2 rejects it.
  • Duplicate names: v1 allows them; v2 rejects them by default.
  • Case-insensitive name matching: v1 does it; v2 is strict by default.
  • Unknown members: both ignore them by default.
go
// src/encoding/json/v2/doc.go:175
// The v1 [encoding/json] and [encoding/json/v2] packages
// interpret some inputs in different ways. In particular:
//
//   - The standard specifies that JSON must be encoded using UTF-8.
//     By default, v1 replaces invalid bytes of UTF-8 in JSON strings
//     with the Unicode replacement character,
//     while v2 rejects inputs with invalid UTF-8.
//     To change the default, specify the [jsontext.AllowInvalidUTF8] option.
//     The replacement of invalid UTF-8 is a form of data corruption
//     that alters the precise meaning of strings.
//
//   - The standard does not specify a particular behavior when
//     duplicate names are encountered within a JSON object,
//     which means that different implementations may behave differently.
//     By default, v1 allows for the presence of duplicate names,
//     while v2 rejects duplicate names.
//     To change the default, specify the [jsontext.AllowDuplicateNames] option.

Each of these is a security decision, not a style decision. The v1 behaviours are all “be liberal in what you accept”, and liberal parsing is how two implementations end up disagreeing about what a document means.

Trade-offs

What the v1 design buys: a stable, universally understood API, one implementation that has been fuzzed for fifteen years, and a scanner that is shared by five different entry points.

What it costs:

  • Two passes over the input. Unmarshal scans the document twice.
  • An indirect call per byte. The scanner’s function-pointer design costs a branch misprediction budget on every byte of every document.
  • Case-insensitive field matching. byFoldedName means {"name": ...} populates a field called Name. It is convenient and it is a source of interoperability bugs.
  • Everything goes through reflect. Even []int is decoded by reflection, with a switch v.Kind() per element.

What v2 buys:

  • Streaming. MarshalWrite and UnmarshalRead never hold the whole document in memory.
  • A real separation of syntax and semantics. jsontext is usable on its own for token-level processing, and encoding/json/v2 is a set of semantic rules on top of it.
  • Stricter, safer defaults. Duplicate names, invalid UTF-8 and ambiguous case matching are all rejected rather than silently accepted.

What v2 costs:

  • It is an experiment. GOEXPERIMENT=jsonv2 is not a supported configuration for production, and the API is still moving.
  • Reflection is still there. The per-type work is cached, but the per-value walk is still a reflect.Value walk. v2 is not “no reflection”; it is “less reflection, cached better”.
  • Behaviour differences. Code that relies on v1’s liberal parsing will break, which is the point but also the cost.

Caveats

json.Unmarshal twice is the price of safety. If you control the input and it is guaranteed well-formed, the first pass is wasted work. But you cannot skip it without reimplementing the parser, and the alternative — a half-filled struct on a syntax error — is worse.

Field name matching is case-insensitive in v1. encoding/json will happily populate a field UserID from the key userid. If you have two fields that differ only in case, v1 picks one of them by an implementation-defined rule.

Numbers become float64 by default. Decoding into an any gives you float64, so a 64-bit integer above 2^53 loses precision. Use json.Number (via Decoder.UseNumber()) or decode into a concrete type.

The ,string tag option is a decoding hack. It means “this field is a number in JSON but a string in Go”, and it applies to the top level of the field only.

jsontext and encoding/json/v2 are only in the tree under the experiment. If you are reading this on Go 1.25, none of it exists. And even on Go 1.28, GOEXPERIMENT=jsonv2 changes the behaviour of the v1 encoding/json package too, because the v2_*.go files replace the old ones in the same package.

Do not benchmark v1 and v2 by swapping the import path. The v1 package under the experiment is a compatibility shim over the new machinery; the timings you get are not the timings of the old implementation.

Wrapping up

The one thing to remember: encoding/json separates scanning from decoding — a hand-written byte-at-a-time state machine that only validates and reports events, and a reflection-driven walker that turns those events into Go values, with the expensive per-type analysis cached in sync.Maps.

The part people get wrong: thinking encoding/json is slow because of reflection. The reflection is not the bottleneck in most programs; the double parse and the per-byte indirect call are, and both are exactly what the v2 experiment is designed to remove.

What to go do about it: if you control the shape of your payloads, use json.Marshal on a struct with concrete field types and skip any entirely — that removes the interface allocations and the switch v.Kind() per value. If you are streaming large documents, look at json.NewEncoder today and at MarshalWrite/UnmarshalRead when the v2 experiment lands.

Further reading

  • src/encoding/json/scanner.go — the v1 scanner state machine and its opcodes
  • src/encoding/json/decode.go — decodeState, value, object, array, literalStore
  • src/encoding/json/encode.go — typeEncoder, the encoder cache, structFields, cachedTypeFields
  • src/encoding/json/stream.go — the streaming Decoder, which drives the same scanner
  • src/encoding/json/jsontext/doc.go — the syntax-versus-semantics argument
  • src/encoding/json/jsontext/state.go — the bit-packed stateEntry state machine
  • src/encoding/json/jsontext/token.go — Kind, Token, and the tokenizer
  • src/encoding/json/jsontext/decode.go — the recursive-descent decoder
  • src/encoding/json/v2/arshal.go — Marshal, MarshalWrite, MarshalEncode, Unmarshal, UnmarshalRead
  • src/encoding/json/v2/fields.go — the per-type struct field cache
  • src/encoding/json/v2/doc.go — the v1-versus-v2 behaviour differences
  • src/internal/goexperiment/exp_jsonv2_on.go — the goexperiment.jsonv2 gate

GoInternalsJSONEncodingPerformance