Cory LaNou

Cory LaNou

JSON v2 Lands in the Go Standard Library

Overview

The encoding/json package is one of the oldest packages in Go (golang), and it shows. It matches field names case-insensitively, silently accepts duplicate keys, and marshals nil slices as null. We've all worked around these for years. Thankfully, Go 1.27 ships the fix: encoding/json/v2 graduates from experiment to standard library. It's stricter by default, significantly faster at unmarshaling, and your existing encoding/json code keeps working. In this article, we'll walk through what actually changes, by example.

Target Audience

This article is aimed at developers that have minimum experience with Go.

In this article, we'll cover the following topics:

  • The new v2 API and how it differs from v1
  • Stricter defaults for case sensitivity and duplicate keys
  • How nil slices and maps marshal in v2
  • The omitzero tag and the time.Time bug it fixes
  • Streaming with MarshalWrite and UnmarshalRead
  • What happens to your existing encoding/json code
  • Finally, we'll end with an example that puts these together

The Basics Look Familiar

The import path is encoding/json/v2. For simple cases, the API looks just like what you already know:

type Course struct {
	Title    string   `json:"title"`
	Students int      `json:"students"`
	Tags     []string `json:"tags"`
}

func main() {
	c := Course{
		Title:    "Intro to Go",
		Students: 4,
		Tags:     []string{"go", "training"},
	}

	data, err := json.Marshal(c)
	if err != nil {
		fmt.Println("marshal error:", err)
		return
	}
	fmt.Println(string(data))

	var out Course
	if err := json.Unmarshal(data, &out); err != nil {
		fmt.Println("unmarshal error:", err)
		return
	}
	fmt.Printf("%+v\n", out)
}
$ go run .

{"title":"Intro to Go","students":4,"tags":["go","training"]}
{Title:Intro to Go Students:4 Tags:[go training]}

--------------------------------------------------------------------------------
Go Version: go1.27rc2

As you can see, Marshal and Unmarshal are still here, and they work just like the versions you already know. The difference is the optional variadic Options on every call, which we'll use throughout this article. No more building a Decoder just to flip one setting.

Stricter By Default

This is the heart of v2. Two behaviors that have surprised Go developers for 15 years are gone. First, v1 matches JSON keys to struct fields case-insensitively. Second, v1 silently accepts duplicate keys and keeps the last value.

So you might be thinking, ok, my API has worked fine with those behaviors for years, why should I care? Both of these have real security implications. Case games and duplicate keys are classic tricks for smuggling data past one service's validation into another service that parses it differently.

Here's both behaviors side by side. We can import both versions in the same file:

type User struct {
	Name string `json:"name"`
}

func main() {
	// Field name case: v1 matches case-insensitively, v2 does not.
	mixedCase := []byte(`{"NAME": "gopher"}`)

	var v1User User
	jsonv1.Unmarshal(mixedCase, &v1User)
	fmt.Printf("v1 name: %q\n", v1User.Name)

	var v2User User
	jsonv2.Unmarshal(mixedCase, &v2User)
	fmt.Printf("v2 name: %q\n", v2User.Name)

	// Duplicate keys: v1 silently keeps the last one, v2 rejects them.
	dupes := []byte(`{"name": "gopher", "name": "snake"}`)

	var v1Dupe User
	err := jsonv1.Unmarshal(dupes, &v1Dupe)
	fmt.Printf("v1 dupe: %q, err: %v\n", v1Dupe.Name, err)

	var v2Dupe User
	err = jsonv2.Unmarshal(dupes, &v2Dupe)
	fmt.Printf("v2 dupe: %q, err: %v\n", v2Dupe.Name, err)
}
$ go run .

v1 name: "gopher"
v2 name: ""
v1 dupe: "snake", err: <nil>
v2 dupe: "gopher", err: jsontext: duplicate object member name "name"

--------------------------------------------------------------------------------
Go Version: go1.27rc2

Notice that with {"NAME": "gopher"}, v1 happily filled in the field even though the tag says name, but v2 left it empty because NAME is not name. With the duplicate keys, v1 silently kept the last value ("snake"), while v2 returned an error telling us exactly which member was duplicated.

If you're consuming an API that sends inconsistent casing, you can opt back in with the MatchCaseInsensitiveNames(true) option, or per field with the case:ignore tag option. Now the behavior is visible in the code instead of surprising you in production.

Nil Slices Now Marshal as []

Every Go developer that has ever built an API has shipped this bug. You return a slice that happens to be nil, and your JavaScript consumers get null where they expected []:

type Report struct {
	Items  []string       `json:"items"`
	Counts map[string]int `json:"counts"`
}

func main() {
	// Both the slice and the map are nil.
	r := Report{}

	v1, _ := jsonv1.Marshal(r)
	fmt.Println("v1:", string(v1))

	v2, _ := jsonv2.Marshal(r)
	fmt.Println("v2:", string(v2))

	// Want the v1 behavior back? There's an option for that.
	v2Nulls, _ := jsonv2.Marshal(r,
		jsonv2.FormatNilSliceAsNull(true),
		jsonv2.FormatNilMapAsNull(true),
	)
	fmt.Println("v2 with options:", string(v2Nulls))
}
$ go run .

v1: {"items":null,"counts":null}
v2: {"items":[],"counts":{}}
v2 with options: {"items":null,"counts":null}

--------------------------------------------------------------------------------
Go Version: go1.27rc2

Notice that v2 marshaled the nil slice as [] and the nil map as {}. If you have consumers that depend on the old null behavior, FormatNilSliceAsNull and FormatNilMapAsNull bring it back.

omitzero Fixes the time.Time Problem

You've probably hit this one too. You put omitempty on a time.Time field, and the zero timestamp shows up in your output anyway. That's because v1's omitempty only checks for JSON emptiness, and a zero time.Time still marshals to a full timestamp string.

The omitzero tag option checks the Go zero value instead, and it respects the IsZero() method that time.Time already has:

type EventV1 struct {
	Name      string    `json:"name"`
	UpdatedAt time.Time `json:"updated_at,omitempty"`
}

type EventV2 struct {
	Name      string    `json:"name"`
	UpdatedAt time.Time `json:"updated_at,omitzero"`
}

func main() {
	// UpdatedAt is the zero value in both cases.
	v1, _ := jsonv1.Marshal(EventV1{Name: "GopherCon"})
	fmt.Println("v1 omitempty:", string(v1))

	v2, _ := jsonv2.Marshal(EventV2{Name: "GopherCon"})
	fmt.Println("v2 omitzero: ", string(v2))
}
$ go run .

v1 omitempty: {"name":"GopherCon","updated_at":"0001-01-01T00:00:00Z"}
v2 omitzero:  {"name":"GopherCon"}

--------------------------------------------------------------------------------
Go Version: go1.27rc2

Notice that the omitzero version dropped the updated_at field entirely, while omitempty let the zero timestamp through. No more "0001-01-01T00:00:00Z" leaking into your API responses, and no more wrapping time.Time in a pointer just to make omitempty work. omitzero also works in v1 as of Go 1.24, so this one you can use today.

Streaming Without io.ReadAll

v2 works directly with readers and writers. UnmarshalRead decodes straight from an io.Reader and MarshalWrite encodes straight to an io.Writer:

type Order struct {
	ID    int     `json:"id"`
	Total float64 `json:"total"`
}

func main() {
	// UnmarshalRead decodes directly from any io.Reader.
	// In a handler this would be r.Body, no io.ReadAll required.
	body := strings.NewReader(`{"id": 1234, "total": 49.99}`)

	var o Order
	if err := json.UnmarshalRead(body, &o); err != nil {
		fmt.Println("unmarshal error:", err)
		return
	}
	fmt.Printf("%+v\n", o)

	// MarshalWrite encodes directly to any io.Writer.
	// In a handler this would be w, the http.ResponseWriter.
	if err := json.MarshalWrite(os.Stdout, o); err != nil {
		fmt.Println("marshal error:", err)
		return
	}
	fmt.Println()
}
$ go run .

{ID:1234 Total:49.99}
{"id":1234,"total":49.99}

--------------------------------------------------------------------------------
Go Version: go1.27rc2

In an HTTP handler, that means json.UnmarshalRead(r.Body, &req) and json.MarshalWrite(w, resp). No intermediate byte slices. For lower level control there's also a new encoding/json/jsontext package that handles JSON at the token level, which is where v2's streaming performance comes from.

Your Existing Code Keeps Working

In Go 1.27, the old encoding/json package is now implemented on top of v2 internally. Your code keeps its v1 semantics: case-insensitive matching, duplicate keys allowed, nil slices as null. You don't have to migrate, and the Go team has said v1 will continue to be supported.

You still get something for free: unmarshaling is significantly faster in the new implementation, and marshaling is at parity. If anything does go sideways, you can build with GOEXPERIMENT=nojsonv2 to get the original implementation back, though that escape hatch is expected to be removed in a future release.

If you followed the experiment, watch out: some experimental features did not graduate. The format struct tag for custom time layouts, for example, isn't in the 1.27 release. time.Time values marshal as RFC 3339. Stick to what the package docs list and you're safe.

Putting It Together

Here's what a strict API endpoint looks like with v2. We decode a signup request directly from a reader, reject unknown fields so typos fail loudly instead of silently dropping data, and marshal a clean response:

type Signup struct {
	Email    string    `json:"email"`
	Course   string    `json:"course"`
	StartsOn time.Time `json:"starts_on"`
	Notes    string    `json:"notes,omitzero"`
}

func main() {
	// A well-formed request works as expected.
	good := strings.NewReader(`{
		"email": "student@example.com",
		"course": "intro-to-go",
		"starts_on": "2026-08-24T00:00:00Z"
	}`)

	var s Signup
	if err := json.UnmarshalRead(good, &s, json.RejectUnknownMembers(true)); err != nil {
		fmt.Println("rejected:", err)
		return
	}
	fmt.Printf("signup: %+v\n", s)

	// A request with a typo'd field name gets rejected instead of
	// silently dropping data.
	bad := strings.NewReader(`{
		"email": "student@example.com",
		"course": "intro-to-go",
		"starts_on": "2026-08-24",
		"nots": "please send an invoice"
	}`)

	var s2 Signup
	err := json.UnmarshalRead(bad, &s2, json.RejectUnknownMembers(true))
	fmt.Println("rejected:", err != nil)

	// And the response comes back with a stable date format and
	// no noise from the empty notes field.
	out, _ := json.Marshal(s)
	fmt.Println(string(out))
}
$ go run .

signup: {Email:student@example.com Course:intro-to-go StartsOn:2026-08-24 00:00:00 +0000 UTC Notes:}
rejected: true
{"email":"student@example.com","course":"intro-to-go","starts_on":"2026-08-24T00:00:00Z"}

--------------------------------------------------------------------------------
Go Version: go1.27rc2

Notice that the request with the typo'd nots field was rejected at the door? With v1, that data would have been silently discarded, and someone would be debugging a missing invoice note three weeks from now.

Summary

As you can see, v2 cleans up 15 years of accumulated sharp edges. While it's only my opinion, I feel this is the biggest standard library change since generics. Stricter parsing closes real security holes, and omitzero and the new nil slice defaults fix the two most common JSON bugs in Go. Your old code keeps working, so try encoding/json/v2 on your next new endpoint and see how much cleaner it reads.

Want More?

If you've enjoyed reading this article, you may find these articles interesting as well:

Ready to level up your Go?

25+ years of combined Go expertise. Fortune 500 clients. Real results.

Go Training

Instructor-led courses—in-person, virtual, or self-paced—that transform your team.

Get Training

Consulting

Expert guidance when you need it—architecture, code review, best practices.

Start Consulting

Code Audit

Deep dive into your codebase. Find issues before they find you.

Request Audit