Go Module 17 – JSON: encoding/json and encoding/json/v2
JSON v2 in Go 1.27: encoding/json/v2 and jsontext
Use encoding/json/v2 and jsontext in Go 1.27: Marshal and UnmarshalRead with options, stricter defaults on real payloads, marshalers and token streams.
What you will learn
- Use the v2 API: Marshal, MarshalWrite, Unmarshal and UnmarshalRead, with options on each call
- Predict what the stricter defaults of v2 do to a payload, and name the option that restores each v1 behaviour
- Encode a type you do not own in a form you choose, with WithMarshalers and MarshalToFunc
- Read a large JSON array as a stream with encoding/json/jsontext, and report a bad element by its position
Before you start
What you’ll learn · Before you start
What you will learn
- Use the v2 API: Marshal, MarshalWrite, Unmarshal and UnmarshalRead, with options on each call
- Predict what the stricter defaults of v2 do to a payload, and name the option that restores each v1 behaviour
- Encode a type you do not own in a form you choose, with WithMarshalers and MarshalToFunc
- Read a large JSON array as a stream with encoding/json/jsontext, and report a bad element by its position
Before you start
On this page
Go 1.27 ships two new packages for JSON, and on the body {"status":"WL","status":"CNF"} the new one disagrees with
the old: encoding/json returns CNF, encoding/json/v2 an error. encoding/json/v2 is a revised API for turning Go values
into JSON and back, with stricter defaults, and encoding/json/jsontext reads and writes JSON as a stream of tokens.
You will call the v2 API, see what its defaults do to real payloads and which options bring v1 back, give types you do not own a JSON form, and stream documents of any size. All of the programs were run on Go 1.27.1; below each one is the output of that run.
Two packages: v2 maps Go values, jsontext reads the syntax
encoding/json/v2 does the old package’s jobs, with new names for some of them. Marshal returns bytes and
MarshalWrite writes to an io.Writer; Unmarshal reads bytes and UnmarshalRead reads an io.Reader to its end.
Every one of them takes options, so each call chooses its own behaviour. encoding/json/jsontext is the layer beneath:
it handles JSON as a sequence of JSON token: One lexical item of JSON text: a string, a number, true, false, null, or one of the delimiters of an object or an array. Colons and commas follow from the grammar, so encoding/json/jsontext has no tokens for them. and raw values, and its state machine refuses
anything that would not be valid JSON. Go 1.25 shipped both as an experiment behind GOEXPERIMENT=jsonv2, and Go 1.27
adds them to the standard library and runs encoding/json itself on the new code. Step through how they call each other:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Code written for v1. Existing code calls encoding/json, the v1 API. Its functions, types and behaviour stay as they were, under the Go 1 compatibility promise.
- v1 runs on v2. Since Go 1.27, encoding/json is implemented by calling encoding/json/v2 with DefaultOptionsV1, the set of options that keeps every v1 behaviour.
- Values become tokens. encoding/json/v2 walks the Go value with reflection and turns it into a sequence of tokens and values, the semantic half of the work.
- Syntax is checked. encoding/json/jsontext keeps a state machine of where it is in the document and refuses any token that would make the output invalid JSON.
- New code calls v2. New code calls encoding/json/v2 directly, choosing its behaviour with options on each call, with nothing in between.
- Two layers, one implementation. Code that needs tokens, such as a streaming reader of a huge array, uses jsontext directly. Every path ends in the same syntax checks.
The usual imports are json "encoding/json/v2" and, where both are needed, jsonv1 "encoding/json". Here is a
booking through the four calls:
// v2_api.go: the encoding/json/v2 calls on one booking: Marshal and MarshalWrite, Unmarshal and UnmarshalRead.
package main
import (
"encoding/json/jsontext"
json "encoding/json/v2"
"fmt"
"os"
"strings"
)
type Booking struct {
PNR string `json:"pnr"`
Passenger string `json:"passenger"`
FarePaise int64 `json:"fare_paise"`
Seats []string `json:"seats"`
}
func main() {
b := Booking{PNR: "4521876390", Passenger: "Asha Rao", FarePaise: 129900, Seats: []string{"B2-34"}}
out, err := json.Marshal(b)
fmt.Printf("Marshal: %s err=%v\n", out, err)
fmt.Println("MarshalWrite, indented:")
if err := json.MarshalWrite(os.Stdout, b, jsontext.WithIndent(" ")); err != nil {
fmt.Println("write:", err)
}
fmt.Println()
var back Booking
err = json.Unmarshal(out, &back)
fmt.Printf("Unmarshal: %+v err=%v\n", back, err)
err = json.UnmarshalRead(strings.NewReader(`{"pnr":"4521876391"}`+"\n"), &back)
fmt.Printf("UnmarshalRead: pnr=%s err=%v\n", back.PNR, err)
err = json.UnmarshalRead(strings.NewReader(`{"pnr":"4521876392"} {"pnr":"4521876393"}`), &back)
fmt.Printf("UnmarshalRead, two values: err=%v\n", err)
} Output
Marshal: {"pnr":"4521876390","passenger":"Asha Rao","fare_paise":129900,"seats":["B2-34"]} err=<nil>
MarshalWrite, indented:
{
"pnr": "4521876390",
"passenger": "Asha Rao",
"fare_paise": 129900,
"seats": [
"B2-34"
]
}
Unmarshal: {PNR:4521876390 Passenger:Asha Rao FarePaise:129900 Seats:[B2-34]} err=<nil>
UnmarshalRead: pnr=4521876391 err=<nil>
UnmarshalRead, two values: err=jsontext: invalid character '{' after top-level value after offset 21
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run v2_api.go
MarshalWrite took jsontext.WithIndent, an option of the syntax layer, on that call alone. UnmarshalRead read the
reader to its end, and the last line shows what that buys: two values in one input are an error, with the byte offset,
where a v1 Decoder would have returned the first value and said nothing.
Checkpoint
Check: The v2 API and the jsontext layer
The stricter defaults, on five inputs
By default jsontext follows RFC 7493, the I-JSON: The Internet JSON message format of RFC 7493: JSON that is UTF-8 throughout, never writes a member name twice in one object, and avoids numbers beyond what an IEEE 754 double holds exactly. profile of JSON, which forbids names written twice and requires valid UTF-8, and v2 changes rules of its own for names, nil values and struct tags. The program sends the same inputs through v1, through v2, and through v2 with the one option that restores v1’s behaviour:
// one_payload.go: the same inputs through encoding/json (v1) and encoding/json/v2, and the option that brings back v1.
package main
import (
jsonv1 "encoding/json"
"encoding/json/jsontext"
json "encoding/json/v2"
"errors"
"fmt"
"strings"
)
type Ticket struct {
PNR string `json:"pnr"`
Status string `json:"status"`
Seats []string `json:"seats"`
}
type Badly struct {
Coach string `json:"coach,omitempty,"` // a stray comma at the end of the tag
}
func decode(title, in string, option json.Options) {
fmt.Println(title, strings.ReplaceAll(in, "\xff", `\xff`)) // show the bad byte as \xff
var a, b, c Ticket
errA := jsonv1.Unmarshal([]byte(in), &a)
errB := json.Unmarshal([]byte(in), &b)
errC := json.Unmarshal([]byte(in), &c, option)
fmt.Printf(" v1: %+v err=%v\n", a, errA)
fmt.Printf(" v2: %+v err=%v\n", b, errB)
fmt.Printf(" v2 + option: %+v err=%v\n", c, errC)
}
func main() {
decode("1. duplicate names", `{"status":"WL","status":"CNF"}`, jsontext.AllowDuplicateNames(true))
decode("2. name in another case", `{"PNR":"4521876390"}`, json.MatchCaseInsensitiveNames(true))
decode("3. a byte that is not UTF-8", "{\"status\":\"C\xffNF\"}", jsontext.AllowInvalidUTF8(true))
fmt.Println("4. a nil slice")
t := Ticket{PNR: "4521876390"}
a, _ := jsonv1.Marshal(t)
b, _ := json.Marshal(t)
c, _ := json.Marshal(t, json.FormatNilSliceAsNull(true))
fmt.Printf(" v1: %s\n v2: %s\n v2 + option: %s\n", a, b, c)
fmt.Println("5. a malformed tag")
x, errX := jsonv1.Marshal(Badly{})
_, errY := json.Marshal(Badly{})
z, errZ := json.Marshal(Badly{}, jsonv1.ReportErrorsWithLegacySemantics(true))
fmt.Printf(" v1: %s err=%v\n", x, errX)
var se *json.SemanticError
if errors.As(errY, &se) {
fmt.Printf(" v2: refused: %v\n", se.Err)
}
fmt.Printf(" v2 + option: %s err=%v\n", z, errZ)
} Output
1. duplicate names {"status":"WL","status":"CNF"}
v1: {PNR: Status:CNF Seats:[]} err=<nil>
v2: {PNR: Status:WL Seats:[]} err=jsontext: duplicate object member name "status"
v2 + option: {PNR: Status:CNF Seats:[]} err=<nil>
2. name in another case {"PNR":"4521876390"}
v1: {PNR:4521876390 Status: Seats:[]} err=<nil>
v2: {PNR: Status: Seats:[]} err=<nil>
v2 + option: {PNR:4521876390 Status: Seats:[]} err=<nil>
3. a byte that is not UTF-8 {"status":"C\xffNF"}
v1: {PNR: Status:C�NF Seats:[]} err=<nil>
v2: {PNR: Status: Seats:[]} err=jsontext: invalid UTF-8 within "/status" after offset 12
v2 + option: {PNR: Status:C�NF Seats:[]} err=<nil>
4. a nil slice
v1: {"pnr":"4521876390","status":"","seats":null}
v2: {"pnr":"4521876390","status":"","seats":[]}
v2 + option: {"pnr":"4521876390","status":"","seats":null}
5. a malformed tag
v1: {} err=<nil>
v2: refused: Go struct field Coach has malformed `json` tag: invalid trailing ',' character
v2 + option: {} err=<nil>
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run one_payload.go
| Input | encoding/json | encoding/json/v2 | Option that brings v1 back |
|---|---|---|---|
| a name written twice | the last value wins | error | jsontext.AllowDuplicateNames(true) |
PNR for the tag pnr |
matched | skipped | json.MatchCaseInsensitiveNames(true) |
| a byte that is not UTF-8 | replaced | error | jsontext.AllowInvalidUTF8(true) |
| a nil slice | null |
[] |
json.FormatNilSliceAsNull(true) |
| a malformed tag | ignored | error | jsonv1.ReportErrorsWithLegacySemantics(true) |
The errors say where they happened: within "/status" is a JSON Pointer: A path to one value inside a JSON document, defined by RFC 6901: member names and array indexes separated by slashes, such as /orders/1/paise for the paise of the second order. encoding/json/v2 names places in its errors this way., the path of the
member at fault. Case 2 is the quiet one, since v2 found no field for PNR and skipped it like any unknown member.
Case 5’s option comes from encoding/json, where the options that exist for v1’s sake live, and it also brings back
v1’s types of error, so fixing the tag is the better cure. Four more defaults matter once you write v2 code:
omitemptydrops a field that would be written asnull,"",{}or[], so it keeps 0 and false.time.Durationhas no default JSON form, so marshalling one is an error until the caller picks a form.- Maps are written in no fixed order unless
json.Deterministic(true)is set. <,>and&are written as they are, unlessjsontext.EscapeForHTML(true)is set.
And v2 gives three kinds of control:
WithMarshalersandMarshalToFuncgive one call a JSON form for a type, without changing the type.MarshalJSONToandUnmarshalJSONFromare a type’s own v2 methods, which write to and read from jsontext directly.- A
jsontext.Decoderreads a document one token or one value at a time, however long the document is.
Key idea
v2 refuses what v1 let through, names written twice, invalid UTF-8 and malformed tags, and matches names exactly; one named option brings back each of v1’s ways with a payload.
Checkpoint
Check: The stricter defaults and their options
JSON forms for types you do not own, and for your own Pro: opens with a Pro, Premium or Ultimate pass
Two kinds of type need a JSON form chosen on purpose: types from other packages, which cannot take new methods, and your own types, which can carry streaming methods. This part gives a form to durations and to amounts of money, and measures what omitempty now keeps.
In this part:
- Giving time.Duration a JSON form, chosen by the caller
- Your own types: MarshalJSONTo and UnmarshalJSONFrom, honoured by v1 calls too
- omitempty now asks about the JSON, and keeps 0 and false
2 runnable examples (Go) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Streaming large documents as tokens, and errors that say where Pro: opens with a Pro, Premium or Ultimate pass
A jsontext.Decoder reads a document one token or one value at a time, so the size of the input stops deciding how much memory a program needs. This part streams 300,000 orders, sorts the errors a stream can survive from the ones it cannot, and covers the two rules of tokens that catch people out.
In this part:
- Reading 300,000 orders in under 1 MiB of live memory
- Bad elements: skip the ones that decode wrong, stop on broken syntax
- Tokens are borrowed, and numbers report what they cannot hold
1 diagram · 3 runnable examples (Go) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
These questions cover the two packages and their defaults, starting with the idea you know least:
Practice that adapts to you
Practise this lesson
10 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (2 to 5 years): How do encoding/json/v2 and encoding/json/jsontext relate to encoding/json in Go 1.27?
encoding/json/v2 is a new major version of the JSON API: Marshal, MarshalWrite, Unmarshal, UnmarshalRead and the rest, each with options and stricter defaults. jsontext is the syntax layer under it, an Encoder and a Decoder of tokens and raw values. Since Go 1.27, encoding/json calls v2 with DefaultOptionsV1, so its behaviour is unchanged apart from the text of some errors. Nothing forces a move: encoding/json stays under the Go 1 compatibility promise.
Three longer questions of the module bank build on this lesson:
-
Mid to senior
An API gateway reads the role member of a JSON body to decide whether a request may continue, then forwards the same bytes to a Go service that decodes them with encoding/json. Why can the two disagree about one body, and how do you make sure they cannot?
-
Mid
A service's config struct has Timeout time.Duration, and its JSON files hold numbers such as 30000000000. After a move to encoding/json/v2, Marshal fails with "no default representation". What are the options, which do you choose for files people edit, and how do the existing files keep loading?
-
Mid to senior
A nightly job reads a 2 GB JSON array of transactions with json.Unmarshal into a slice and is killed for running out of memory. Rewrite it with encoding/json/jsontext so that memory stays flat, every bad record is reported with its position, and one bad record does not stop the run.
Key takeaways
- encoding/json/v2 maps Go values to JSON with Marshal, MarshalWrite, Unmarshal and UnmarshalRead, each taking options, and encoding/json/jsontext reads and writes the syntax as tokens and raw values.
- Since Go 1.27 encoding/json runs on v2 with DefaultOptionsV1, and v2’s own defaults are stricter: names written twice, invalid UTF-8 and malformed tags are errors, and names match exactly.
- Each of v1’s ways with a payload comes back with one option, such as
jsontext.AllowDuplicateNamesorjson.FormatNilSliceAsNull.
Terms in this lesson
- I-JSON
- The Internet JSON message format of RFC 7493: JSON that is UTF-8 throughout, never writes a member name twice in one object, and avoids numbers beyond what an IEEE 754 double holds exactly.
- JSON Pointer
- A path to one value inside a JSON document, defined by RFC 6901: member names and array indexes separated by slashes, such as /orders/1/paise for the paise of the second order. encoding/json/v2 names places in its errors this way.
- JSON token
- One lexical item of JSON text: a string, a number, true, false, null, or one of the delimiters of an object or an array. Colons and commas follow from the grammar, so encoding/json/jsontext has no tokens for them.
References
- Package encoding/json/v2 (The Go Authors)
- Package encoding/json/jsontext (The Go Authors)
- Go 1.27 Release Notes: encoding/json/v2 and encoding/json/jsontext (The Go Authors)
- Package encoding/json: Migrating to v2 (The Go Authors)
- A new experimental Go API for JSON (The Go Authors)
- Tutorial: Working with JSON (The Go Authors)
- RFC 7493: The I-JSON Message Format (Internet Engineering Task Force (IETF))
- RFC 6901: JavaScript Object Notation (JSON) Pointer (Internet Engineering Task Force (IETF))
- encoding/json/v2 source: errors.go (errorModalVerb) (The Go Authors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress