Go's JSON v2 experiment separates syntax from Go values
The experimental JSON API changes ambiguous defaults and introduces streaming customization. Separating syntax from Go value conversion explains both the compatibility strategy and the performance design.
A JSON document can pass through several systems that disagree about duplicate member names. One may retain the first value, another the last, and another may merge them. A parser that accepts the document has not necessarily established a meaning that every participant shares. Go's experimental JSON revision treats that ambiguity as an API design problem, alongside the cost of repeatedly parsing nested values.
The September 9 introduction to JSON v2 explains the new encoding/json/v2 and encoding/json/jsontext packages available experimentally in Go 1.25. They require build-time opt-in and may change. Their design separates JSON syntax processing from the conversion between JSON and Go values, giving each layer a defined role.
I favor this kind of explicit separation. Go's standard JSON package makes ordinary cases approachable, but a widely used API accumulates behavior that existing programs depend on. A new namespace gives the project room to correct defaults while describing how old callers can retain their contracts.
Syntax does not require reflection
The tagged jsontext package documentation defines encoders and decoders that operate on tokens and complete values. A token can be a literal, string, number, or container delimiter. A value can represent an entire object or array as raw JSON text.
That distinction permits a program to process structure without first allocating a matching Go object graph. A caller can read a token sequence or forward a complete value while the syntax machinery checks its position in the surrounding document. Conversion to a particular struct or map belongs to another operation.
The encoder and decoder maintain state so that a sequence of writes or reads must satisfy the grammar. Callers do not separately supply punctuation tokens for commas and colons because the JSON structure determines where those characters belong. The API combines incremental access with an implementation that understands the current syntactic position.
The package documents RFC 7493 behavior as its default, which is a stricter subset of RFC 8259. In particular, valid UTF-8 and unique member names have defined treatment. Other options can select different behavior, but a caller must make that choice explicitly. This gives a service a clearer starting point than silently accepting a document whose interpretation varies across implementations.
Go value conversion has its own rules
The v2 package documentation maps JSON values to Go types and defines field tags. Struct field matching is case-sensitive by default. Unknown members remain ignored unless the caller requests rejection, which is separate from rejecting duplicate names within an object.
These are different policy questions. A member can be unknown to one Go struct while remaining a valid, unique JSON name. A duplicate name can be ambiguous even when both occurrences match a known field. Describing strictness as one boolean would obscure the decisions that a protocol needs to make.
The field-omission rules also distinguish Go values from JSON values. omitzero asks whether the Go value is zero, optionally through its IsZero method. The v2 meaning of omitempty asks whether the serialized JSON value is empty, including null or an empty array or object. Those tests can produce different results for the same field type.
This matters when omission carries protocol meaning. A zero numeric value is a Go zero value, but its JSON representation is not one of v2's empty JSON values. A caller selecting a tag needs to decide whether it intends to omit a language-level zero or a particular serialized representation. The documentation makes those definitions available for review.
Defaults change observable messages
V2 rejects invalid UTF-8 and duplicate object names by default. It also serializes nil slices and maps as empty arrays and objects, respectively. Existing v1 programs can depend on the older null representation, so importing v2 changes more than implementation speed.
An empty collection and a null value can represent different states in an application protocol. The new default avoids exposing Go's nil collection representation when the protocol expects a collection, but it cannot determine what a particular existing endpoint promised. Migration requires choosing the intended representation at that boundary.
Case-sensitive matching similarly reduces implicit equivalence between field names. A struct field tagged json:"role" does not acquire every capitalization as another default spelling. Protocols that require a different matching rule can select it, but the default no longer supplies a broad fallback automatically.
These choices are reasons for a separate package version. Go's compatibility requirements make it difficult to replace old defaults in place, even when maintainers regard them as flaws. A new namespace can state a different contract while a compatibility implementation preserves the contract of existing imports.
Streaming customization removes repeated work
The custom-method interfaces add MarshalJSONTo and UnmarshalJSONFrom. They receive a jsontext.Encoder or jsontext.Decoder. A method must process one JSON value and must not retain the supplied encoder or decoder pointer.
The older byte-slice interfaces require a complete serialized value. Before calling an unmarshaler, the library needs to find the end of that value. The method then parses the same bytes to interpret them. When custom methods recursively call ordinary unmarshaling, nested structures can repeat that work over overlapping regions.
The streaming interface gives the custom method access to the existing decoder state. It can consume the value through the same syntax machinery, avoiding the requirement to begin again from an isolated byte slice. Serialization has a corresponding benefit because the method writes into an encoder that already handles structure and formatting.
A type may implement both old and new methods. The documented precedence favors the streaming method, and the implementations should agree under default options. That agreement matters for a type used by callers on both APIs. The optimization should not accidentally introduce a different representation merely because the library selected a different customization interface.
Options can follow a nested call
The v2 options implementation represents both individual settings and combined settings. Later properties override earlier ones. The same option type works across the syntax and semantic packages, so a caller can pass the relevant policies without inventing a parallel configuration object.
Custom streaming methods can inspect encoder or decoder options. This addresses a limitation of byte-slice methods, where a method that starts a new decoder can lose the caller's settings. A nested type is often exactly where an unknown-field policy or number representation must continue to apply.
Caller-provided marshalers and unmarshalers also allow representation changes for types the caller does not own. That avoids adding methods to an unrelated type merely to satisfy one protocol. It creates another explicit policy boundary, since the call selecting the customization determines how the type appears in that serialization operation.
I find that caller control useful when it remains visible at the boundary. A representation belongs to the protocol using it, and a library type should not need a permanent global method for every possible encoding. The API provides both type-owned methods and operation-owned customization so their responsibilities need not be conflated.
The experiment also exercises compatibility
The Go 1.25 release notes explain that enabling GOEXPERIMENT=jsonv2 changes the implementation behind the existing encoding/json import as well. The compatibility path aims to preserve its marshaling and unmarshaling behavior, although exact error text can differ. Enabling the experiment and importing the v2 API are therefore distinct steps.
The build-time setting also makes the implementation choice part of the executable produced by the experiment. It is not a runtime switch that can change a live service between the two implementations. That permits two different investigations. Existing tests can exercise the new implementation under v1 behavior. Separate code can evaluate v2 defaults and customization directly. A result from one does not establish compatibility for the other, because the selected semantic options differ.
The September article explicitly says that the old package will remain supported. V2 remains an experiment whose outcome is unsettled and whose APIs have no ordinary compatibility guarantee yet. This design nevertheless exposes the substantive problem clearly. Syntax processing owns grammar and stream position. Go conversion owns field mapping and representation, while options communicate the policy a caller needs across those operations.