Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

Free core, Pro depth: 8 of 20 minutes free
Free: Two packages: v2 maps Go values, jsontext reads the syntax · The stricter defaults, on five inputs. With a pass: 2 in-depth parts and 3 interview answers, about 12 minutes.

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:

Interactive · step by step The JSON packages of Go 1.27, and who calls whom Step through one call of the old API and one of the new, from your code down to the bytes.
The JSON packages of Go 1.27, and who calls whom, step 6 of 6. Showing your code, encoding/json (now runs on v2), encoding/json/v2 (options on every call), encoding/json/jsontext, JSON text. In focus: encoding/json/jsontext.json.Marshal(v)json.Marshal(v, opts…)DefaultOptionsV1tokenschecked syntaxyour codeencoding/jsonnow runs on v2encoding/json/v2options on every callencoding/json/jsontexttokens and raw valuesJSON texta reader or a writer

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. New code calls v2. New code calls encoding/json/v2 directly, choosing its behaviour with options on each call, with nothing in between.
  6. 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: Marshal, MarshalWrite, Unmarshal and UnmarshalRead Go · v2_api.go
// 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

  1. Question 1 of 2 v2_api.go called json.UnmarshalRead on a reader holding two JSON values. What happened?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It returned an error about the character after the first value, with its byte offset

    The output: "jsontext: invalid character '{' after top-level value after offset 21". Lesson 1's strict decoder needed a second Decode for this; UnmarshalRead does it by default.

  2. Question 2 of 2 Which package keeps the state machine that refuses a sequence of tokens that would not be valid JSON?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: encoding/json/jsontext

    Every path, the v1 API included, ends in jsontext's syntax checks, as the figure's last step showed.

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: five inputs through v1, v2, and v2 with one option Go · one_payload.go
// 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:

  • omitempty drops a field that would be written as null, "", {} or [], so it keeps 0 and false.
  • time.Duration has 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, unless jsontext.EscapeForHTML(true) is set.

And v2 gives three kinds of control:

  • WithMarshalers and MarshalToFunc give one call a JSON form for a type, without changing the type.
  • MarshalJSONTo and UnmarshalJSONFrom are a type’s own v2 methods, which write to and read from jsontext directly.
  • A jsontext.Decoder reads 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

  1. Question 1 of 2 With the defaults of v2, what does Unmarshal of {"PNR":"4521876390"} do to a field tagged json:"pnr"?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It leaves the field empty and returns no error

    Case 2 of one_payload.go printed {PNR: …} err=<nil> for v2: the quietest of the changes, because nothing fails.

  2. Question 2 of 2 After a move to v2, an app version crashes because a nil slice now arrives as [] instead of null. Which option keeps the old output for that call?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: json.FormatNilSliceAsNull(true)

    Each v1 behaviour comes back with one option, passed on the calls that need it.

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.

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.

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.

  1. Question 1 of 10 Which function of encoding/json/v2 writes a value's JSON to an io.Writer?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: MarshalWrite

    v2_api.go wrote the booking to standard output with MarshalWrite and the option jsontext.WithIndent.

  2. Question 2 of 10 Since Go 1.27, what happens when existing code calls json.Marshal of encoding/json?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: It calls encoding/json/v2 with DefaultOptionsV1, so the behaviour stays v1's

    The release notes add one caveat: the exact text of error messages may differ.

  3. Question 3 of 10 How could a program use encoding/json/v2 before Go 1.27?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: In Go 1.25 and 1.26, by building with GOEXPERIMENT=jsonv2

    Go 1.27 makes them ordinary packages of the standard library.

  4. Question 4 of 10 How does a v2 call ask for indented output?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: It passes jsontext.WithIndent(" ") among the call's options

    Every function of v2 takes ...Options, so the same call can mix syntax and semantic options.

  5. Question 5 of 10 What does encoding/json/v2 do by default with {"status":"WL","status":"CNF"}?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: It returns an error, duplicate object member name "status"

    one_payload.go printed the error for v2 and CNF for v1 and for v2 with the option.

  6. Question 6 of 10 A string holds the byte 0xFF. What do v1 and v2 do by default?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: v1 replaces it with U+FFFD and returns no error; v2 returns an error

    Case 3 of one_payload.go: v1 printed C, the replacement character and NF; v2 reported invalid UTF-8 within "/status".

  7. Question 7 of 10 The tag json:"coach,omitempty," ends with a stray comma. What do v1 and v2 do with the struct?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: v1 marshals it without a word; v2 returns an error naming the malformed tag

    A test that marshals every response type once with v2 finds such tags before clients do.

  8. Question 8 of 10 A v2 error ends with within "/status". What is "/status"?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: A JSON Pointer to the member where the error happened

    /orders/1/paise would mean the paise member of the second element of the orders array.

  9. Question 9 of 10 A team moves to v2 with no options. A response type has Count int tagged omitempty. What changes for clients when Count is 0?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: They start receiving "count":0, because v2's omitempty keeps 0

    This is the first of the four further defaults the lesson lists: v1 left the zero out, and v2 writes it, because 0 is not an empty JSON value.

  10. Question 10 of 10 Older partners send PNR in capitals. How does one v2 Unmarshal call keep accepting them?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: json.Unmarshal(b, &t, json.MatchCaseInsensitiveNames(true))

    Case 2 of one_payload.go: with the option, PNR filled the field again.

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:

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

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.AllowDuplicateNames or json.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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.