SeriesPart 3 of 7 // Go 1.27
GoWriting
Aug 17, 2026
14 min read
encoding/json

Go 1.27 and encoding/json/v2: The Engine Changed, the Contract Didn't

go1.27rc1 replaces the engine under encoding/json with encoding/json/v2 internals, and existing callers will not notice: the v1 shims restore every v1 semantic, including two the v2 docs call security relevant. What moved is performance, split by payload shape and direction, and what moved is opt-in, behind an explicit import.

A cast-iron treadle sewing machine with its ornate antique casing hinged fully open, revealing a precise modern mechanism of machined bearings and a compact drive unit inside, connected by a single burnt-orange drive belt, threaded and ready to sew on a worn workbench.

Written and benchmarked against go1.27rc1, cut from release-branch.go1.27 on 18 June 2026. The release notes carried a draft warning at the time of writing. Final release is 25 August 2026.

I went into this post expecting to write about encoding/json getting strict in Go 1.27. That is the assumption I started with, and it is wrong. I want to correct it in public, because the correction is more useful than the assumption would have been.

Here is what actually happens. go1.27rc1 reimplements encoding/json on top of encoding/json/v2. The package you already import is now a set of shims that call into the v2 engine with json.DefaultOptionsV1() applied on every call. I decoded a document with a duplicate object member name under rc1's encoding/json and got the same answer 1.26 gives: the last one wins, no error. I marshalled a Go string with a stray 0xff byte and got the same answer too: the byte becomes U+FFFD, silently. Nothing about encoding/json's behaviour moved. The engine underneath it did.

What I got wrong first

The two behaviours that make this worth writing about are the ones the encoding/json/v2 documentation itself calls security relevant: duplicate object member names, and invalid UTF-8. Two JSON parsers that disagree on either one can be made to disagree about what a document says. An authenticator that reads the first "subject" in a document and an executor that reads the last can be handed the same bytes and authorise two different people. RFC 8259 leaves duplicate names undefined, so both parsers are conforming, and both are dangerous together.

My first draft of this post said Go 1.27 fixes that, because encoding/json/v2 does. It rejects the duplicate. It rejects the invalid byte. What I had not done yet was run the same inputs through encoding/json itself under rc1, with no code changes, no new import, nothing. When I did, encoding/json took both inputs without complaint, exactly as it always has. The fix exists. It is not where I assumed it would be.

The accurate framing, and the one this whole post is built on: v1 API, v2 engine, v1 semantics. Strictness is opt-in, and the only way to opt in is to import encoding/json/v2 directly.

The shim, and why your code will not notice

cmd/jsonsemantics in the companion repo runs three inputs through both APIs and prints what each one did. This is the captured output under go1.27rc1, unedited:

go1.27rc1: encoding/json against encoding/json/v2, same inputs
==============================================================
 
duplicate object name
  the member "subject" appears twice
  input: {"subject":"alice","action":"read","subject":"root","resource":"/etc/shadow"}
 
  encoding/json        accepted
                       subject="root" action="read" resource="/etc/shadow"
  encoding/json/v2     rejected
                       jsontext: duplicate object member name "subject"
 
invalid UTF-8
  the subject carries a lone 0xff byte
  input: {"subject":"ali\xffce","action":"read","resource":"/v1/invoices"}
 
  encoding/json        accepted
                       subject="ali<U+FFFD>ce" action="read" resource="/v1/invoices"
  encoding/json/v2     rejected
                       jsontext: invalid UTF-8 within "/subject" after offset 15

encoding/json accepted both. That is not rc1 leaving a bug in place, it is the shims doing their job. DecodeWithV1 in semantics.go is a one-line wrapper around jsonv1.Unmarshal, and it stays a one-line wrapper because v1's signature has no room for options:

// DecodeWithV1 decodes through the v1 API. Under go1.27rc1 this runs on the v2
// engine, with json.DefaultOptionsV1() applied by the shim in v2_decode.go.
// The caller cannot pass options: v1's Unmarshal signature has none, which is
// exactly why v1 semantics are not configurable from v1.
func DecodeWithV1(in []byte) Outcome {
	out := Outcome{API: "encoding/json"}
	out.Err = jsonv1.Unmarshal(in, &out.Value)
	if out.Err != nil {
		out.Decision = Rejected
	}
	return out
}

json.Options exists in rc1 as an alias for the type encoding/json/v2 uses, and encoding/json exports constructors for it, json.DefaultOptionsV1() among them. But Unmarshal and Marshal still take no options. The only way to apply one is to call a v2 function, which means the migration path is always: import encoding/json/v2, keep passing DefaultOptionsV1, then turn dials off one at a time. There is no way to reach for strictness without leaving the v1 import behind.

One thing worth knowing before relying on encoding/json's output as a golden file: I found one byte-level difference between 1.26 and rc1 in v1's marshal output, and only one. Both releases substitute U+FFFD for invalid UTF-8. go1.26.5 writes the six-character JSON escape \ufffd. go1.27rc1 writes the three raw UTF-8 bytes. Both documents decode to the same Go string, so nothing observable breaks, but a byte-for-byte comparison against a fixture recorded under 1.26 will fail. GOEXPERIMENT=nojsonv2 puts the escape back, which is what lets me attribute this to the engine rather than to anything else in the release.

There is also no GODEBUG gating any of this on a module's Go version. A go 1.26 module compiled by rc1 gets the v2 engine underneath encoding/json just the same as a go 1.27 module does. That absence of a version gate is what makes the cross-version benchmark below a fair comparison rather than an artefact of two different code paths.

What the benchmarks show

The engine swap changes no observable behaviour for encoding/json callers, and it changes the performance profile substantially. I ran compare/, a nested go 1.26 module in the companion repo, under both go1.26.5 and go1.27rc1 and reduced the result with benchstat. Three payload shapes: status_flat (a nine-field health response), config_nested (a deployment manifest eight levels deep), logbatch_array (512 structured access log entries in one array).

Marshalling got slower on every shape I measured:

Benchmarkgo1.26.5go1.27rc1change
Marshal/status_flat230.4ns369.9ns+60.55% (p=0.000)
Marshal/config_nested2.980µs4.922µs+65.17% (p=0.000)
Marshal/logbatch_array142.8µs193.0µs+35.17% (p=0.000)

Unmarshalling into a struct went the other way, and by a similar margin:

Benchmarkgo1.26.5go1.27rc1change
UnmarshalStruct/status_flat1293.5ns585.8ns-54.72% (p=0.000)
UnmarshalStruct/config_nested18.352µs9.294µs-49.36% (p=0.000)
UnmarshalStruct/logbatch_array776.5µs373.7µs-51.88% (p=0.000)

UnmarshalAny, decoding the same three payloads into map[string]any instead of a struct, went the wrong way again, closer to the marshal numbers than the struct-unmarshal ones:

Benchmarkgo1.26.5go1.27rc1change
UnmarshalAny/status_flat1.406µs1.537µs+9.32% (p=0.000)
UnmarshalAny/config_nested17.81µs23.53µs+32.12% (p=0.000)
UnmarshalAny/logbatch_array874.7µs966.4µs+10.48% (p=0.000)

The streaming pair, Encoder/Decoder over logbatch_array, splits the same way as marshal and struct-unmarshal do: EncoderStream/logbatch_array is +32.23% (p=0.000), DecoderStream/logbatch_array is -40.71% (p=0.000).

The benchstat output reports a geomean of -6.20% across all eleven benchmarks. I am not leading with that number, and I would not use it on its own: it is an average of numbers pulling in opposite directions by tens of percent each, and it does not describe any workload I could point to. A service that mostly marshals is slower under rc1. A service that mostly unmarshals into typed structs is substantially faster. A service that decodes untyped JSON into map[string]any is slower again, in roughly the same direction as marshalling.

The allocation counts explain some of the shape. UnmarshalStruct/status_flat drops from 10 allocations to 1 under rc1 (-90.00%, p=0.000), and its bytes-per-op drops from 440 to 112 (-74.55%, p=0.000), which tracks with the 54.72% timing improvement on the same benchmark. Marshal/status_flat moves from 1 allocation to 2 (+100.00%, p=0.000) and 240 to 352 bytes (+46.67%, p=0.000), which tracks with marshalling getting slower. None of this is a coincidence of measurement; the full breakdown, including B/op and allocs/op for every one of the eleven benchmarks, is in results/02-json-v2-benchstat.txt in the companion repo.

Opting into strictness

Getting the two security-relevant rejections requires importing encoding/json/v2 and calling its Unmarshal or Marshal directly. With no options, encoding/json/v2 defaults to RFC 7493: unique member names, mandatory UTF-8, case-sensitive field matching.

// DecodeWithV2 decodes through the v2 API. With no options it uses v2 defaults,
// which are RFC 7493: unique member names, mandatory UTF-8, case-sensitive
// matching. Pass options to move it back towards v1, or further towards strict.
func DecodeWithV2(in []byte, opts ...jsonv2.Options) Outcome {
	out := Outcome{API: "encoding/json/v2"}
	out.Err = jsonv2.Unmarshal(in, &out.Value, opts...)
	if out.Err != nil {
		out.Decision = Rejected
	}
	return out
}

Against DuplicateSubject, that produces jsontext: duplicate object member name "subject". Against InvalidUTF8Subject, it produces jsontext: invalid UTF-8 within "/subject" after offset 15. Both are *jsontext.SyntacticError, both carry a JSON pointer to the offending member, and neither text is randomised, so both are safe to assert exactly in a test.

Encoding refuses the same class of input rather than corrupting it. Marshalling AuthRequest{Subject: "ali\xffce", ...} through encoding/json writes {"subject":"ali<U+FFFD>ce", ...} and returns no error. Through encoding/json/v2 it returns jsontext: invalid UTF-8 within "/subject" after offset 11 and writes nothing.

There is a third input in semantics.go worth knowing about before treating v2 as strictly stricter in every direction: a document that spells "subject" as "SUBJECT".

case-mismatched names
  the document spells "subject" as "SUBJECT"
  input: {"SUBJECT":"alice","Action":"read","resource":"/v1/invoices"}
 
  encoding/json        accepted
                       subject="alice" action="read" resource="/v1/invoices"
  encoding/json/v2     accepted
                       subject="" action="" resource="/v1/invoices"

Both accept it. Neither errors. v1 folds the case and fills the fields in. v2 does not match "SUBJECT" against subject at all, treats it as an unknown member, ignores it under default options, and leaves Subject and Action zero. A struct that used to arrive populated now arrives empty, with no error to notice by. Case sensitivity in v2 is a matching rule, not a validation rule; it only becomes an error once RejectUnknownMembers(true) is also set, which turns "ignored" into "rejected" for every unmatched member, case mismatches included.

The Options that move between the two

options.go names five fixed positions so a codebase can adopt one by name instead of re-deriving a call convention at every call site.

// V1Semantics is what the rc1 shims apply to every encoding/json call. Passing
// it to a v2 function reproduces v1 behaviour exactly: duplicate object names
// accepted with the last winning, invalid UTF-8 replaced with U+FFFD,
// case-insensitive field matching, and v1's error types and wording.
//
// This is the option set to reach for first when porting a call site to v2,
// because it changes nothing.
func V1Semantics() jsonv2.Options {
	return jsonv1.DefaultOptionsV1()
}
// V2Strict adds the option most services actually want on an ingress boundary:
// refuse a document that carries a member the destination type has no field
// for. Under v2 error semantics this reports as a *jsonv2.SemanticError
// wrapping jsonv2.ErrUnknownName.
//
// v1 had DisallowUnknownFields on Decoder for this, which only worked on the
// streaming path. RejectUnknownMembers works on Unmarshal too.
func V2Strict() jsonv2.Options {
	return jsonv2.JoinOptions(
		jsonv2.DefaultOptionsV2(),
		jsonv2.RejectUnknownMembers(true),
	)
}

The remaining three: V2Defaults() is jsonv2.DefaultOptionsV2() with nothing added, the RFC 7493 baseline. V2CaseInsensitive() layers MatchCaseInsensitiveNames(true) on top of V2Defaults, for a producer whose wire format was never tidied up and needs v2's guarantees without breaking on case. V1SemanticsStrictText() is the one I expected to reach for first, and the one with a catch worth knowing before I did:

// V1SemanticsStrictText is the migration position that matters: keep every v1
// behaviour a codebase depends on, but stop accepting the two inputs the v2
// documentation calls out as security relevant.
//
// The catch is documented on purpose, because it cost me time. DefaultOptionsV1
// implies ReportErrorsWithLegacySemantics, which rewrites errors into v1's flat
// shapes. So a rejection under this option set arrives as a *json.SyntaxError
// carrying the bare text "duplicate object member name", not as a
// *jsontext.SyntacticError, and errors.Is(err, jsontext.ErrDuplicateName) is
// false. The input is rejected, which is the point, but the error is not
// machine-classifiable. Anything that needs to tell the two rejections apart
// has to either match on text or drop ReportErrorsWithLegacySemantics.
func V1SemanticsStrictText() jsonv2.Options {
	return jsonv2.JoinOptions(
		jsonv1.DefaultOptionsV1(),
		jsontext.AllowDuplicateNames(false),
		jsontext.AllowInvalidUTF8(false),
	)
}

Under V2Defaults(), the duplicate-name input produces jsontext: duplicate object member name "subject", a *jsontext.SyntacticError, and errors.Is(err, jsontext.ErrDuplicateName) is true. Under V1SemanticsStrictText(), the same input produces the bare text duplicate object member name, a *json.SyntaxError, and the same errors.Is check is false. Both option sets refuse the input, which is the point of turning the dial off. Only one leaves behind an error a caller can classify without matching on a string.

Turning the engine off entirely

GOEXPERIMENT=nojsonv2 is not a runtime switch. It removes encoding/json/v2 and encoding/json/jsontext from the build, and it removes both at once, not just the one named in the flag:

package importv2
	imports encoding/json/jsontext: build constraints exclude all Go files in /Users/ajitem/sdk/go1.27rc1/src/encoding/json/jsontext
package importv2
	imports encoding/json/v2: build constraints exclude all Go files in /Users/ajitem/sdk/go1.27rc1/src/encoding/json/v2

Every file under encoding/json/v2 carries a build constraint on the goexperiment.jsonv2 tag. With the experiment off, the directory contains no buildable Go file for that constraint, so the import fails before type checking gets anywhere near it. There is no GODEBUG fallback and no way to detect the experiment at run time other than reading the environment yourself or noticing that runtime.Version() grows a suffix: go1.27rc1-X:nojsonv2. encoding/json itself keeps working under the experiment, on the old engine, with the pre-1.27 UTF-8 escaping restored. Treat the flag as a temporary escape hatch to compare engines, the way I have used it here, not as a supported way to ship two code paths.

A streaming redactor

encoding/json/jsontext is the third package rc1 ships alongside the shims and v2: a syntax-only layer that reads and writes JSON tokens with no Go type attached to any of them. That is what makes Redactor in redact.go possible. It walks a document as tokens, rewrites the values sitting at a fixed set of JSON pointers, and copies everything else through unread and unmaterialised.

func (w *walker) value() error {
	if replacement, ok := w.replacements[jsontext.Pointer(w.path)]; ok {
		// SkipValue consumes the whole value, however large, without
		// materialising it. This is where an object-valued pointer becomes
		// cheap rather than expensive.
		if err := w.dec.SkipValue(); err != nil {
			return fmt.Errorf("jsonv2demo: skipping redacted value at %s: %w", w.path, err)
		}
		if err := w.enc.WriteToken(jsontext.String(replacement)); err != nil {
			return fmt.Errorf("jsonv2demo: writing redaction at %s: %w", w.path, err)
		}
		return nil
	}
 
	switch w.dec.PeekKind() {
	case '{':
		if err := w.copyToken(); err != nil {
			return err
		}
		for w.dec.PeekKind() != '}' {
			name, err := w.dec.ReadToken()
			if err != nil {
				return fmt.Errorf("jsonv2demo: reading member name under %s: %w", w.path, err)
			}
			// A Token borrows the decoder's buffer and is valid only until the
			// next read, so consume it before anything else happens. Passing
			// name.String() straight into a function that does not retain it
			// keeps the string off the heap: see the comment on Token.String
			// in the standard library about function outlining.
			mark := len(w.path)
			w.path = appendPointerToken(w.path, name.String())
			if err := w.enc.WriteToken(name); err != nil {
				return fmt.Errorf("jsonv2demo: writing member name at %s: %w", w.path, err)
			}
			if err := w.value(); err != nil {
				return err
			}
			w.path = w.path[:mark]
		}
		return w.copyToken()
 
	case '[':
		if err := w.copyToken(); err != nil {
			return err
		}
		for i := 0; w.dec.PeekKind() != ']'; i++ {
			mark := len(w.path)
			w.path = appendPointerIndex(w.path, i)
			if err := w.value(); err != nil {
				return err
			}
			w.path = w.path[:mark]
		}
		return w.copyToken()
 
	default:
		// A scalar. ReadValue hands back the raw bytes exactly as they appeared,
		// so 1.0000000000000002 and 9007199254740993 survive the copy. That is
		// the second thing a map round trip cannot do.
		v, err := w.dec.ReadValue()
		if err != nil {
			return fmt.Errorf("jsonv2demo: reading value at %s: %w", w.path, err)
		}
		if err := w.enc.WriteValue(v); err != nil {
			return fmt.Errorf("jsonv2demo: writing value at %s: %w", w.path, err)
		}
		return nil
	}
}

I measured it against RedactViaMap, which does the job the way it has always been done: decode the whole document into map[string]any, walk to each target, assign, re-encode. Both redact the same payment webhook in payload.go, at five JSON pointers, over two sizes of the same payload shape (64 and 512 line items). Timings are not comparable here, they were captured with six benchmark processes contending for 14 cores in the same pass that produced everything else in this post, so I am reporting only allocs/op and B/op, both of which are contention-immune:

Benchmarkallocs/opB/op
Redact/jsontext_stream/64_items10030752
Redact/jsontext_stream/512_items100108576
Redact/map_round_trip/64_items130553058
Redact/map_round_trip/512_items9394427395

The streaming redactor's allocation count is flat at 100 per document across a 64-item and a 512-item payload. The map[string]any round trip's is not: it climbs from 1305 to 9394 across the same size increase. RedactToDiscard/jsontext_stream/512_items, which writes to io.Discard instead of a buffer, brings the streaming figure down to 98 allocations and 18440 bytes, which is roughly the floor the walk itself costs once the caller already has somewhere to put the output.

Two things the map round trip gets wrong that the streaming version does not, both pinned in TestRedactPreservesWhatTheMapRoundTripDestroys: it reorders object members, because encoding/json sorts map keys on the way back out, so the redacted document no longer matches the sender's byte order. And it rounds every number through float64. payload.go sets Sequence: 9007199254740993, which is 2^53 + 1. A float64 cannot hold that exactly, and the map round trip returns 9007199254740992 instead, one less than what arrived. The streaming redactor copies the digits with ReadValue/WriteValue and never parses them into a number at all, so the value survives.

The pointer construction underneath the walk is worth one more pair of numbers. jsontext.Pointer.AppendToken is the readable way to build a pointer and it allocates a new string on every call: BenchmarkPointerAppendToken reports 1 allocation and 32 bytes. Redactor instead appends into a reused byte buffer and truncates it on the way back up the recursion, which BenchmarkAppendPointerToken reports at 0 allocations, 0 bytes. Over a 512-item document visiting every value position, that difference is most of what separates 100 allocations from several thousand.

What tripped me up

Four things, in the order I hit them.

V1SemanticsStrictText refuses the two dangerous inputs and then hands back an error a caller cannot classify programmatically. DefaultOptionsV1 implies ReportErrorsWithLegacySemantics, which rewrites the rejection into v1's flat shape: the pointer, the offset, and the errors.Is-able sentinel are all gone, replaced by a bare string on a *json.SyntaxError. The input is refused, which is the goal, but code downstream that needs to tell "duplicate name" apart from "some other syntax error" now has to match on text, or drop ReportErrorsWithLegacySemantics and accept v2's error shapes elsewhere too.

encoding/json/v2 does not reject case-mismatched member names under its defaults. It ignores them, silently, the same way it ignores any member the destination struct has no field for. The risk this creates is the opposite of the one I expected: v1 folding the case meant a mismatched field still got populated. v2 leaves it zero, with nothing raised. It only becomes visible once RejectUnknownMembers(true) is set.

v2 partially fills the destination before returning an error. On the duplicate-name input, Unmarshal under V2Defaults() returns a rejection and leaves Subject: "alice", Action: "read" set in the struct, because it fails at the duplicate rather than before starting. Reading that struct after checking the error is wrong, and the mistake is easy to make when the error-handling branch is the one that logs the struct.

*jsonv2.SemanticError.Error() is not stable text. src/encoding/json/v2/errors.go builds its modal verb once per process by ranging over a two-key map literal and returning whichever key comes out first, riding on Go's randomised map iteration order:

var errorModalVerb = sync.OnceValue(func() string {
	for phrase := range map[string]struct{}{"cannot": {}, "unable to": {}} {
		return phrase // use whichever phrase we get in the first iteration
	}
	return ""
})

The source comment calls this Hyrum-proofing. Running one binary 200 times gave 181 renderings starting json: cannot unmarshal and 19 starting json: unable to unmarshal. A test that asserts the exact string passes locally on most runs and fails in CI on roughly one run in ten. errors.Is(err, jsonv2.ErrUnknownName) is the part that never moves; assert that instead.

Takeaways

The engine under encoding/json changed in go1.27rc1 and the contract did not. Every encoding/json call I tested behaves under rc1 exactly as it did under go1.26.5, duplicate names, invalid UTF-8, and case folding included. Do not tell a team that upgrading to Go 1.27 makes their existing JSON handling strict. It does not, and saying so sends people looking for a bug fix that will not be there.

What did move is performance, and it moved in different directions depending on the operation and the payload shape, not as one number. Marshalling got slower on every shape I measured, up to 65.17% on config_nested. Unmarshalling into a typed struct got faster on every shape I measured, up to 54.72% on status_flat. Unmarshalling into map[string]any moved the other way again, up to 32.12% on config_nested. A service dominated by one of these operations will feel this release differently from a service dominated by another.

Strictness against the two security-relevant behaviours is available and it is opt-in: import encoding/json/v2, call its Unmarshal or Marshal directly, and RFC 7493 defaults apply. GOEXPERIMENT=nojsonv2 exists to compare engines, not to ship two behaviours side by side; it removes the packages at build time rather than switching anything at run time.

The jsontext streaming redactor holds its allocation count flat across an eightfold increase in line items on the payment webhook I measured, 64 to 512, where the map[string]any equivalent grows roughly sevenfold in allocations over the same increase. It also avoids two correctness problems the map round trip has quietly always had: reordered object members and integers above 2^53 rounded through float64. I am not calling this "faster", only more consistent in what it allocates and more faithful to what it read; the timings live in Phase B of the companion repo's results and I have not quoted any here.

Every code sample and every number above comes from the 02-json-v2 directory of the companion repository: github.com/asahasrabuddhe/go-1-27-bench/tree/main/02-json-v2. Read NOTES.md there before repeating any of these claims; it lists eight discrepancies I hit while building this, including the ones in this post, and states plainly what the post must not claim.

Series contents

01
Go 1.27: A Release Map, Ordered by What Can Break You
Read
02
Generic Methods in Go 1.27: What Changed, What Didn't
Read
03
Go 1.27 and encoding/json/v2: The Engine Changed, the Contract Didn't
Current
04
The go1.27 Goroutine Leak Profile: What Reachability Can and Cannot See
Coming soon // Aug 19, 2026
05
Go 1.27's Runtime and Compiler: A Buffer Removed, Labels Added, Allocation Sped Up
Coming soon // Aug 21, 2026
06
Post-Quantum Signatures with crypto/mldsa: What ML-DSA Actually Costs
Coming soon // Aug 23, 2026
07
Go 1.27: net/http, and a Tour of the Testing Toolchain
Coming soon // Aug 24, 2026