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/jsonis 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/jsontextfor syntax andencoding/json/v2for semantics, both gated behindGOEXPERIMENT=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.
// 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.
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 valueThe v2 experiment replaces the two-layer arrangement with a three-layer one:
+---------------------------------------------+
| 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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 = 10000Ten 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:
// 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:
// 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:
// 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)
}// 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:
// 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:
// 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:
// src/encoding/json/encode.go:705
type structFields struct {
list []field
byExactName map[string]*field
byFoldedName map[string]*field
}// 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:
// 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.quotedFirst 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:
// 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:
// 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 = 1Every existing v1 file is excluded when the experiment is on:
// src/encoding/json/scanner.go:5
//go:build !goexperiment.jsonv2and 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:
// src/encoding/json/v2_decode.go:5
//go:build goexperiment.jsonv2The 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:
// 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:
// 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.
// 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:
// 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:
// 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:
// 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
}// 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) {// 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:
// 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.
// 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.
Unmarshalscans 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.
byFoldedNamemeans{"name": ...}populates a field calledName. It is convenient and it is a source of interoperability bugs. - Everything goes through
reflect. Even[]intis decoded by reflection, with aswitch v.Kind()per element.
What v2 buys:
- Streaming.
MarshalWriteandUnmarshalReadnever hold the whole document in memory. - A real separation of syntax and semantics.
jsontextis usable on its own for token-level processing, andencoding/json/v2is 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=jsonv2is 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.Valuewalk. 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 opcodessrc/encoding/json/decode.go—decodeState,value,object,array,literalStoresrc/encoding/json/encode.go—typeEncoder, the encoder cache,structFields,cachedTypeFieldssrc/encoding/json/stream.go— the streamingDecoder, which drives the same scannersrc/encoding/json/jsontext/doc.go— the syntax-versus-semantics argumentsrc/encoding/json/jsontext/state.go— the bit-packedstateEntrystate machinesrc/encoding/json/jsontext/token.go—Kind,Token, and the tokenizersrc/encoding/json/jsontext/decode.go— the recursive-descent decodersrc/encoding/json/v2/arshal.go—Marshal,MarshalWrite,MarshalEncode,Unmarshal,UnmarshalReadsrc/encoding/json/v2/fields.go— the per-type struct field cachesrc/encoding/json/v2/doc.go— the v1-versus-v2 behaviour differencessrc/internal/goexperiment/exp_jsonv2_on.go— thegoexperiment.jsonv2gate