Go's original encoding/json API made JSON approachable, but years of compatibility constraints left it with surprising defaults and limited room to evolve. Go 1.27 makes encoding/json/v2 a supported standard-library package: stricter where ambiguity can become a security problem, configurable at each call, and paired with a low-level streaming package called encoding/json/jsontext.
Every snippet runs on Go 1.27 via Codapi sandboxes directly in your browser, with no local toolchain required.
What changed
A new API on a new implementation
The familiar operations are still recognizable. Marshal returns bytes and Unmarshal fills a Go value:
package main
import (
"encoding/json/v2"
"fmt"
)
type Launch struct {
Service string `json:"service"`
Region string `json:"region"`
Ready bool `json:"ready"`
}
func main() {
original := Launch{Service: "catalog", Region: "sa-east-1", Ready: true}
data, err := json.Marshal(original)
if err != nil {
panic(err)
}
fmt.Println(string(data))
var decoded Launch
if err := json.Unmarshal(data, &decoded); err != nil {
panic(err)
}
fmt.Printf("%+v\n", decoded)
}The difference is that every operation accepts options. Policy can live at the boundary instead of being baked into a wrapper type:
package main
import (
"encoding/json/v2"
"fmt"
)
type Event struct {
Sequence int64 `json:"sequence"`
Name string `json:"name"`
}
func main() {
event := Event{Sequence: 9007199254740993, Name: "checkout.completed"}
plain, _ := json.Marshal(event)
quoted, _ := json.Marshal(event, json.StringifyNumbers(true))
fmt.Println("default:", string(plain))
fmt.Println("quoted: ", string(quoted))
}The existing encoding/json package now uses the v2 implementation internally while preserving v1 behavior. You can migrate call sites gradually; the old API remains supported.
Safer defaults
Duplicate names are rejected
JSON permits syntax whose meaning is not consistent across implementations. Duplicate object names are the sharpest example. The v1 API accepts both values and leaves the last one in the destination:
package main
import (
"encoding/json"
"fmt"
)
type Permission struct {
Role string `json:"role"`
}
func main() {
input := []byte(`{"role":"viewer","role":"admin"}`)
var permission Permission
err := json.Unmarshal(input, &permission)
fmt.Printf("role=%q err=%v\n", permission.Role, err)
}V2 rejects the object instead of choosing an interpretation:
package main
import (
"encoding/json/v2"
"errors"
"fmt"
"encoding/json/jsontext"
)
type Permission struct {
Role string `json:"role"`
}
func main() {
input := []byte(`{"role":"viewer","role":"admin"}`)
var permission Permission
err := json.Unmarshal(input, &permission)
var syntaxError *jsontext.SyntacticError
fmt.Println("rejected:", err != nil)
fmt.Println("syntactic error:", errors.As(err, &syntaxError))
}This matters when one service authorizes a document and another service executes it. They should not disagree about which duplicate value wins.
Field matching is case-sensitive
V1 loosely matches object names to struct fields. Different capitalization, underscores, and dashes can reach the same field:
package main
import (
"encoding/json"
"fmt"
)
type Profile struct {
DisplayName string `json:"displayName"`
}
func main() {
for _, input := range []string{
`{"displayName":"exact"}`,
`{"DISPLAYNAME":"upper"}`,
} {
var profile Profile
_ = json.Unmarshal([]byte(input), &profile)
fmt.Printf("%q\n", profile.DisplayName)
}
}V2 uses exact matching by default. Opt in to loose matching only for a field or call site that needs it:
package main
import (
"encoding/json/v2"
"fmt"
)
type Profile struct {
DisplayName string `json:"displayName"`
}
func main() {
input := []byte(`{"display_name":"Ada"}`)
var strict Profile
_ = json.Unmarshal(input, &strict)
var compatible Profile
_ = json.Unmarshal(input, &compatible, json.MatchCaseInsensitiveNames(true))
fmt.Printf("strict=%q compatible=%q\n", strict.DisplayName, compatible.DisplayName)
}The struct tag json:"displayName,case:ignore" narrows that compatibility rule to one field.
Invalid UTF-8 is an error
V1 silently replaces invalid bytes in JSON strings with the Unicode replacement character:
input := []byte{'"', 'o', 'k', ':', 0xff, '"'}
var value string
err := json.Unmarshal(input, &value)
fmt.Printf("value=%q err=%v\n", value, err)V2 rejects the same payload, preserving the distinction between accepted data and corrupted data:
input := []byte{'"', 'o', 'k', ':', 0xff, '"'}
var value string
err := json.Unmarshal(input, &value)
fmt.Printf("value=%q rejected=%v\n", value, err != nil)Compatibility is available through jsontext.AllowInvalidUTF8(true), but accepting damaged strings should be a deliberate boundary decision.
Policies at the call site
Reject unknown members
Unknown object names remain ignored by default, which is useful for forward-compatible clients:
package main
import (
"encoding/json/v2"
"fmt"
)
type Command struct {
Action string `json:"action"`
}
func main() {
input := []byte(`{"action":"deploy","force":true}`)
var command Command
err := json.Unmarshal(input, &command)
fmt.Printf("command=%+v err=%v\n", command, err)
}At an API boundary, rejecting misspellings and unexpected fields is often safer:
package main
import (
"encoding/json/v2"
"fmt"
)
type Command struct {
Action string `json:"action"`
}
func main() {
input := []byte(`{"action":"deploy","froce":true}`)
var command Command
err := json.Unmarshal(input, &command, json.RejectUnknownMembers(true))
fmt.Println("rejected typo:", err != nil)
if err != nil {
fmt.Println(err)
}
}Use strict decoding for requests you own and tolerant decoding for documents designed to evolve independently.
Nil collections encode as empty collections
V2 encodes nil maps and slices according to their JSON kind. This avoids making clients handle null and an empty collection as separate cases:
package main
import (
"encoding/json/v2"
"fmt"
)
type Dashboard struct {
Widgets []string `json:"widgets"`
Filters map[string]string `json:"filters"`
}
func main() {
data, _ := json.Marshal(Dashboard{})
fmt.Println(string(data))
}When wire compatibility requires null, restore it explicitly:
package main
import (
"encoding/json/v2"
"fmt"
)
type Dashboard struct {
Widgets []string `json:"widgets"`
Filters map[string]string `json:"filters"`
}
func main() {
data, _ := json.Marshal(
Dashboard{},
json.FormatNilSliceAsNull(true),
json.FormatNilMapAsNull(true),
)
fmt.Println(string(data))
}Options compose, so an application can define one policy bundle with json.JoinOptions and reuse it across boundaries.
Streaming APIs
Read and write without intermediate byte slices
MarshalWrite sends JSON directly to an io.Writer. It is a natural fit for HTTP responses, compressed streams, and files:
package main
import (
"bytes"
"encoding/json/v2"
"fmt"
)
type Metric struct {
Name string `json:"name"`
Value float64 `json:"value"`
}
func main() {
var output bytes.Buffer
err := json.MarshalWrite(&output, Metric{Name: "queue.depth", Value: 17})
fmt.Println(output.String())
fmt.Println("error:", err)
}UnmarshalRead consumes an io.Reader directly:
package main
import (
"encoding/json/v2"
"fmt"
"strings"
)
type Metric struct {
Name string `json:"name"`
Value float64 `json:"value"`
}
func main() {
reader := strings.NewReader(`{"name":"latency.p99","value":42.5}`)
var metric Metric
err := json.UnmarshalRead(reader, &metric, json.RejectUnknownMembers(true))
fmt.Printf("%+v err=%v\n", metric, err)
}These functions express ownership more clearly and avoid the mandatory io.ReadAll step common around the v1 byte-slice API.
Syntax-level processing with jsontext
Walk tokens without choosing Go destination types
jsontext.Decoder treats JSON as a validated stream of tokens. This is useful for proxies, inspectors, and transformations that care about syntax rather than a fixed schema:
decoder := jsontext.NewDecoder(strings.NewReader(`{"zone":"south","replicas":3}`))
for {
token, err := decoder.ReadToken()
if err == io.EOF {
break
}
if err != nil {
panic(err)
}
fmt.Printf("kind=%v token=%v depth=%d\n", token.Kind(), token, decoder.StackDepth())
}The decoder's state machine catches structurally invalid sequences and exposes byte offsets and JSON Pointers for diagnostics.
Format and canonicalize raw values
jsontext.Value can transform valid JSON without unmarshaling it into any. Indent preserves member order while making a value readable:
value := jsontext.Value(`{"region":"south","ports":[8080,8081]}`)
if err := value.Indent(); err != nil {
panic(err)
}
fmt.Println(string(value))Canonicalize produces the deterministic representation defined by RFC 8785, including object-name ordering and normalized numbers:
value := jsontext.Value(`{"z":1.0,"a":"first","m":1e+2}`)
if err := value.Canonicalize(); err != nil {
panic(err)
}
fmt.Println(string(value))Canonical JSON is useful before hashing or signing a document. It does not decide whether the document is semantically valid for your application.
Migration strategy
Move one boundary at a time
A safe migration does not begin with a repository-wide import rewrite. Start where stricter behavior has the most value:
- Add v2 decoding to an owned API boundary.
- Turn on
RejectUnknownMemberswhere clients should follow a fixed schema. - Test duplicate names, capitalization, invalid UTF-8, nil collections, and map ordering explicitly.
- Add compatibility options only for behavior required by the wire contract.
- Move streaming boundaries to
MarshalWriteandUnmarshalReadwhere allocations matter. - Adopt
jsontextonly where syntax-level access simplifies the design.
The build-time escape hatch GOEXPERIMENT=nojsonv2 restores the original v1 implementation if the new engine reveals a compatibility problem. It is a temporary diagnostic tool, not a migration plan.
Quick reference
| Need | API |
|---|---|
| Encode or decode bytes | json.Marshal, json.Unmarshal |
| Stream through I/O | json.MarshalWrite, json.UnmarshalRead |
| Use a token encoder or decoder | json.MarshalEncode, json.UnmarshalDecode |
| Reject extra object members | json.RejectUnknownMembers(true) |
| Restore loose field matching | json.MatchCaseInsensitiveNames(true) |
| Quote numeric values | json.StringifyNumbers(true) |
Encode nil collections as null | json.FormatNilSliceAsNull, json.FormatNilMapAsNull |
| Stabilize map output | json.Deterministic(true) |
| Read raw token streams | jsontext.Decoder |
| Pretty-print raw JSON | jsontext.Value.Indent |
| Produce RFC 8785 JSON | jsontext.Value.Canonicalize |
| Temporarily restore the old engine | GOEXPERIMENT=nojsonv2 |
For complete behavior and migration details, see the encoding/json/v2 and encoding/json/jsontext documentation.
Comments