Programming · free software · the open webSubscribe via RSS

Go’s new JSON packages separate syntax from meaning

Go 1.27’s stable JSON v2 API separates JSON text processing from mapping JSON values to Go types. Its stricter defaults can expose assumptions at an interface boundary, so migration is a data-contract change to test deliberately.

A release makes the split available

On 4 November 2026, the Go team announced that encoding/json/v2 and encoding/json/jsontext were stable and generally available with Go 1.27. This is a distinct story from the release’s language changes: it is a redesigned standard-library boundary for reading and writing JSON. The old encoding/json package remains supported, so the announcement starts a migration option rather than a forced switch. [1] [4]

The key design idea is to separate two questions that older APIs often make one call appear to answer. Is this byte sequence valid JSON text, and how should a valid JSON value correspond to a particular Go type? Those are related operations, but they are not the same contract.

Syntax and semantics have different jobs

encoding/json/jsontext works at the syntax layer. Its encoder and decoder process JSON tokens and complete raw values, can validate their structure, and do not need to know whether an object represents a User, a configuration record, or anything else. That makes it useful when a program needs to inspect or transform JSON structure before deciding on a Go representation. [3]

encoding/json/v2 works at the semantic layer: it maps Go values to JSON and JSON values back to Go values. It can use byte slices or readers and writers, and it accepts options that configure how that mapping behaves. The separation is practical. A gateway that only needs to validate and relay a JSON value has a reason to use syntax operations; an application that wants a typed request object needs semantic mapping. [2]

Custom types can also choose how deeply they participate. Existing MarshalJSON and UnmarshalJSON methods remain supported, while streaming MarshalJSONTo and UnmarshalJSONFrom interfaces let custom code work through the syntax encoder or decoder. A type that only builds a complete byte slice may keep working, but it does not automatically gain the streaming behavior of the new interfaces. [1]

Defaults are part of the wire contract

The most important migration differences are observable at the boundary. V2 rejects invalid UTF-8 and duplicate object member names by default; it matches JSON names to struct fields case-sensitively; and it encodes nil Go slices and maps as empty arrays and objects. Its omitempty option is also defined in terms of empty JSON values, including null, empty strings, arrays, and objects. These choices are not cosmetic when another service parses the bytes or when a consumer depends on the previous representation. [1] [2]

Consider a request containing both role and Role. A case-insensitive decoder can map both names toward the same Go field even though a different component might treat them as distinct. Duplicate names create a related disagreement: one component may keep the first value, another the last, and another may reject the request. If one service checks a request and another acts on it, their interpretation must match. Rejecting ambiguity makes that boundary easier to reason about, but it can also reject traffic that an old decoder accepted. [2]

Likewise, changing a nil slice from JSON null to [] can alter a generated response even if both decode into a useful Go value. That matters for clients with strict schemas, snapshots, signatures, or caches keyed by serialized bytes. A successful Go round trip is not proof that every peer sees the same representation.

Migrate by identifying relied-on behavior

The Go project designed v1 and v2 to coexist. V1 remains covered by the Go 1 compatibility promise, and the project says an application can migrate call sites gradually. Compatibility options let a call use v2 while retaining selected v1 behaviors, or start with v1-compatible settings and tighten one behavior at a time. A dependency that uses one package does not by itself require every caller to use the same one. [1]

A useful first pass is to inventory the boundary rather than mechanically replace import paths. Record representative inputs and outputs, including duplicate names, invalid UTF-8, differently cased field names, nil and empty collections, omitted fields, and unknown members. Then decide which outcomes the protocol actually promises. If null and [] mean different things to a client, preserve that distinction intentionally; if duplicate names have no legitimate meaning, rejection may be the clearer contract. Options should document such decisions in code instead of hiding them in a broad compatibility setting. [2]

Custom serialization deserves its own inventory. Search for MarshalJSON, UnmarshalJSON, and json.RawMessage, then test how their inputs and outputs interact with v2 options. A migration can be correct for ordinary structs and still change a custom type’s behavior. Tests should compare the external bytes or decoded meaning that consumers depend on, not only compare two Go values after a round trip.

Treat performance as a measured outcome

The Go announcement reports that v2 unmarshaling can be significantly faster in benchmarks, with improvements up to 10x, while marshaling is roughly at parity and can be slightly faster or slower. Those are reported benchmark results, not a promise for an application’s payloads. The post says streaming custom methods can unlock further gains, but adopting those methods is additional implementation work. [1]

Measure the actual workload after semantic tests pass: representative payload sizes, common and malformed inputs, allocation counts, and the custom types that dominate processing. Keep correctness and speed as separate acceptance questions. The new API gives Go programs a clearer place to express syntax policy and type mapping; the application still has to decide what its wire format means.

References

  1. A new Go API for JSON
  2. encoding/json/v2 package documentation
  3. encoding/json/jsontext package documentation
  4. Go 1.27 Release Notes

Suggest a correction · All posts