Huny's Dev Blog
huny.dev

Go 1.27 encoding/json/v2 and jsontext GA: 6 Default Behavior Changes from v1 and True Streaming Migration

Explore Go 1.27's newly stable encoding/json/v2 and encoding/json/jsontext packages, how separating syntax from semantics enables up to 10x faster unmarshaling, and the 6 default behavior changes from v1.

Hun Jang
Hun Jang Nov 8, 2026

A conceptual illustration showing Go 1.27's two-layer JSON architecture separating reflection-free syntactic streaming (encoding/json/jsontext) from strict semantic validation and type mapping (encoding/json/v2). This is not an actual tool screenshot.

After six years of design and community testing as an experiment since Go 1.25, the encoding/json/v2 and encoding/json/jsontext packages are now stable and generally available (GA) in Go 1.27. Because the Go 1 compatibility promise prevented fixing longstanding behavioral flaws in the original encoding/json package (v1)—including silent UTF-8 corruption, duplicate key acceptance, case-insensitive field matching, and quadratic buffering inside custom UnmarshalJSON([]byte) methods—the Go team introduced a clean v2 namespace built atop a reflection-free streaming syntax engine. The Go Blog: A new Go API for JSON, encoding/json/v2 Docs, encoding/json/jsontext Docs

The core engineering takeaway: migrating to encoding/json/v2 is both a streaming performance upgrade (up to 10x faster unmarshaling) and a security hardening boundary—but its stricter defaults will reject or alter payloads that v1 silently accepted. By default, v2 rejects duplicate object keys and invalid UTF-8 with an error, enforces strict case-sensitive struct field matching, marshals nil slices and maps as [] and {} instead of null, and refuses to serialize bare time.Duration values without an explicit format. Fortunately, Go 1.27 re-implements v1 under the hood in terms of v2 options, allowing seamless interoperability and gradual, callsite-by-callsite migration.

This guide analyzes the two-layer architecture, the six default behavior changes, and incremental migration patterns based on the official Go Blog announcement and the encoding/json/v2 and encoding/json/jsontext package documentation. It does not report production service throughput benchmarks; the local verification script below tests the six behavioral boundaries between v1 and v2.

1. Separating Syntax (jsontext) from Semantics (json/v2) for True Streaming

In encoding/json (v1), syntactic token scanning and reflection-driven Go type mapping were tightly coupled. Worse, the UnmarshalJSON([]byte) interface required a complete byte slice with no trailing data, forcing v1 to scan the entire JSON value once to find its boundary before handing the slice to UnmarshalJSON to parse all over again—causing O(N2)O(N^2) performance degradation on deeply nested schemas such as kubernetes/kube-openapi#315.

Go 1.27 resolves this by splitting JSON processing into two distinct layers:

Architectural Layer
Syntactic Layer: encoding/json/jsontext
Semantic Layer: encoding/json/v2
Primary Responsibility
Processes JSON purely according to its RFC 8259 grammar (Encode / Decode tokens and values)
Maps JSON values to and from Go types such as structs, slices, and maps (Marshal / Unmarshal)
Go Reflection Dependency
Zero reflection. Operates on raw bytes, jsontext.Token, and jsontext.Value (the new home of v1's RawMessage) without heap allocations
Uses reflection and caller/type customizers while delegating all underlying token reads and writes to jsontext.Encoder and jsontext.Decoder
Streaming Interfaces
NewEncoder(io.Writer, ...Options) and NewDecoder(io.Reader, ...Options) stream tokens without buffering the whole payload in memory
Introduces MarshalerTo (MarshalJSONTo(*jsontext.Encoder) error) and UnmarshalerFrom (UnmarshalJSONFrom(*jsontext.Decoder) error)
io.Reader Trailing Data Handling
ReadValue() and ReadToken() process consecutive top-level values in a stream
UnmarshalRead(io.Reader, out, ...Options) consumes until io.EOF and rejects trailing junk tokens with an error (fixing the classic json.NewDecoder(r).Decode(v) pitfall)

By implementing MarshalJSONTo and UnmarshalJSONFrom, custom types read and write tokens directly on the shared streaming Decoder or Encoder—preserving options down the call stack via Decoder.Options() and eliminating quadratic re-parsing.

2. Six Behavioral Differences Between v1 and v2 Defaults

Before replacing imports from encoding/json to encoding/json/v2, review the six default behavior changes summarized below:

Behavior Dimension
Legacy encoding/json (v1 Default)
New encoding/json/v2 (v2 Default)
Security & Migration Impact / Override Option
1. Duplicate Object Names
Allowed (e.g., in {"role":"user","role":"admin"}, the last value silently wins)
Returns an error immediately
Closes parser-differential vulnerabilities (such as CVE-2017-12635). Override only when required via jsontext.AllowDuplicateNames(true)
2. Invalid UTF-8 Bytes
Allowed (silently replaces bad bytes with U+FFFD, corrupting downstream strings)
Returns an error immediately per RFC 8259
Prevents silent data corruption. Legacy callers can opt out via jsontext.AllowInvalidUTF8(true)
3. Struct Field Name Matching
Case-insensitive fallback by default (slow and prone to unexpected field collisions)
Strict case-sensitive match by default
Faster unmarshaling and predictable binding. Opt in per field with `json:",case:ignore"` or globally with json.MatchCaseInsensitiveNames(true)
4. nil Slices and nil Maps
Marshaled as JSON null
Marshaled as [] (empty array) and {} (empty object)
Eliminates null-pointer surprises in strictly typed API clients. Restore v1 output with FormatNilSliceAsNull(true) / FormatNilMapAsNull(true)
5. omitempty vs. omitzero
omitempty checks the Go zero value (which failed to omit empty structs struct{}{})
omitempty omits when the encoded JSON value would be empty (null, "", [], {}); omitzero checks the Go zero value or IsZero() bool
Use `json:",omitzero"` for types like time.Time or custom value types implementing IsZero() bool
6. time.Duration Serialization
Marshals as raw integer nanoseconds
Reports a SemanticError by default unless a format is specified
Prevents unit-mismatch bugs; specify an explicit format option or custom marshaler for structs containing time.Duration

3. Interoperability and Gradual Callsite Migration

Because Go 1.27 implements encoding/json (v1) under the hood by calling encoding/json/v2 with jsonv1.DefaultOptionsV1(), packages using v1 and v2 work together seamlessly. A struct marshaled via v2 automatically honors v1 MarshalJSON methods on nested third-party types, and a v1 caller automatically benefits when a dependency implements v2's faster MarshalJSONTo method.

You can also mix option sets at individual callsites or override third-party types without wrapper structs using WithMarshalers and WithUnmarshalers:

4. Verifying the v1 vs. v2 Behavioral Boundaries Locally

To audit how existing test payloads behave under v1 versus v2 semantics before upgrading a service, the local verification script below models the four most common breaking boundaries: invalid UTF-8 rejection, duplicate-key rejection, case-sensitive field binding, and nil collection formatting.

Scope Verified Locally

Running this verification script in a local environment confirmed four boundary cases:

  1. Invalid UTF-8 payload (b'{"Role": "admin\xff"}'): Silently replaced and accepted in v1 mode, but rejected immediately with err_invalid_utf8 in v2 mode.
  1. Duplicate-key privilege override (b'{"Role": "user", "Role": "admin"}'): Accepted in v1 mode (binding "admin"), blocked in default v2 mode with err_duplicate_name:Role, and permitted only when allow_dup=True (jsontext.AllowDuplicateNames(true)) is explicitly passed.
  1. Case-mismatched key (b'{"role": "admin"}' against untagged field Role): Bound in v1 mode via case-insensitive fallback, but ignored (None) under default v2 case-sensitive matching.
  1. nil slice and map serialization: Emits "null" in v1 mode versus "[]" and "{}" in v2 mode.

5. Pre-Migration Checklist for Go 1.27 Services

  • Audit untagged struct fields for case mismatches: Any Go struct field relying on v1's case-insensitive fallback to bind lowercase JSON keys without an explicit `json:"..."` tag will stop binding in v2 unless tagged or marked with `json:",case:ignore"`.
  • Update contract tests expecting null for empty slices or maps: Check golden test files and API consumers that explicitly compare items === null; either update them for [] / {} or pass json.FormatNilSliceAsNull(true).
  • Migrate recursive custom unmarshalers to UnmarshalJSONFrom: Replace allocation-heavy UnmarshalJSON([]byte) methods on hot-path types with UnmarshalJSONFrom(*jsontext.Decoder) error to unlock streaming performance gains.

References

Last verified: November 8, 2026

Korean version: Go 1.27 encoding/json/v2 및 jsontext 정식 출시 해부: v1과 달라진 6가지 보안·직렬화 기본값과 스트리밍 전환하기Go 1.27 encoding/json/v2 및 jsontext 정식 출시 해부: v1과 달라진 6가지 보안·직렬화 기본값과 스트리밍 전환하기

추천 글