How to Migrate to encoding/json/v2 in Go 1.27: A Practical Guide with Code Examples

By , Tim Engineering APIPedia · Published · Updated · 10 min read

The official Go Blog released a new JSON API guide. Learn the difference between v1 and v2, create a tight API client and webhook, then migrate safely to Go 1.27.

APIPedia article cover: Migrating to json/v2 in Go 1.27, with a fact card from Go Blog November 4, 2026 that unmarshals are up to 10 times faster and duplicate keys are errors by default

encoding/json/v2 is a new JSON package in the stable Go standard library since Go 1.27, complete with the companion package encoding/json/jsontext. On November 4, 2026, the Go team published the article "A new Go API for JSON" along with a migration guide. This is important because v2 is stricter by default and the unmarshal process is much faster.

This guide is practical: we install Go 1.27, see how v1 and v2 behave differently with executable code, create a strict API client and webhook handler, and then create a safe migration path for the old project. We have tested all examples with go1.27.0 linux/amd64.

What actually changed in Go 1.27?

Go 1.27 was released August 19, 2026. In this version encoding/json/v2 and encoding/json/jsontext are no longer experimental. The legacy package encoding/json remains and remains supported indefinitely, but now runs on the v2 engine with all v1 compatibility options turned on.

So there are two pieces of news at once:

  • The old code still works. You are not required to change imports.
  • New code should use v2. The default is safer: UTF-8 is invalid and duplicate key names are rejected outright.

Encoding/json/v2 chronology: go-json-experiment experiment repo created February 19, 2021, proposal 71497 on January 31, 2025, available in Go 1.25 via GOEXPERIMENT=jsonv2 in August 2025, proposal accepted May 2026, Go 1.27 released August 19, 2026 with v2 stable, and Go Blog articles A new Go API for JSON published November 4, 2026

The v2 core API looks like this (quoted from Go Blog):

func Marshal(in any, opts ...Options) (out []byte, err error)
func MarshalWrite(out io.Writer, in any, opts ...Options) error
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error

func Unmarshal(in []byte, out any, opts ...Options) error
func UnmarshalRead(in io.Reader, out any, opts ...Options) error
func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error

Pay attention to parameter opts ...Options. In v1, setting the decoder behavior meant creating json.Decoder and then calling the methods one by one. In v2, the option is simply tacked on to the last argument.

Step 1: How to set up Go 1.27?

Check your Go version, then upgrade in go.mod projects:

go version
# go version go1.27.0 linux/amd64

# naikkan versi bahasa di go.mod
go mod edit -go=1.27
go mod tidy

If the local toolchain is still old, download it from go.dev/dl. No need for GOEXPERIMENT anything else: the v2 package is available straight away.

Step 2: How does the behavior of v1 and v2 differ in practice?

The quickest way to understand v2 is to compare them directly. Save the following code as main.go:

package main

import (
	jsonv1 "encoding/json"
	"encoding/json/v2"
	"fmt"
)

type User struct {
	Name string   `json:"name"`
	Age  int      `json:"age"`
	Tags []string `json:"tags"`
}

func main() {
	// 1. Key duplikat
	dup := []byte(`{"name":"Budi","name":"Admin","age":30}`)
	var a, b User
	e1 := jsonv1.Unmarshal(dup, &a)
	e2 := json.Unmarshal(dup, &b)
	fmt.Printf("v1: %+v err=%v\nv2: %+v err=%v\n", a, e1, b, e2)

	// 2. Nama key beda huruf besar/kecil
	up := []byte(`{"NAME":"Sari","AGE":25}`)
	var c, d User
	jsonv1.Unmarshal(up, &c)
	json.Unmarshal(up, &d)
	fmt.Printf("v1: %+v\nv2: %+v\n", c, d)

	// 3. Slice nil
	o1, _ := jsonv1.Marshal(User{Name: "Rina"})
	o2, _ := json.Marshal(User{Name: "Rina"})
	fmt.Println(string(o1))
	fmt.Println(string(o2))
}

Run with go run .. Here's the original output from our test:

v1: {Name:Admin Age:30 Tags:[]} err=<nil>
v2: {Name:Budi Age:0 Tags:[]} err=jsontext: duplicate object member name "name"
v1: {Name:Sari Age:25 Tags:[]}
v2: {Name: Age:0 Tags:[]}
{"name":"Rina","age":0,"tags":null}
{"name":"Rina","age":0,"tags":[]}

The first line is the most important. In v1, a payload with double "name" is accepted and the last value wins. If one system component reads the first key and another component reads the last key, an attacker can smuggle values. This kind of vulnerability has occurred in CouchDB (CVE-2017-12635). v2 rejected it from the start.

Comparison table of v1 and v2 encoding/json behavior: duplicate keys accepted in v1 but error in v2, invalid UTF-8 replaced by wildcard in v1 but error in v2, key name matching case-insensitive in v1 and case-sensitive in v2, slice and map nil become null in v1 and [] or {} in v2, meaning of omitempty changed, time.Duration error in v2, along with compatible options for each case

Summary of differences and return options

Casev1v2 (default)Option for old behavior
Duplicate keysAccepted, last value winsErrorjsontext.AllowDuplicateNames(true)
UTF-8 is invalidReplaced wildcardsErrorjsontext.AllowInvalidUTF8(true)
Key names are in different capitalsStill fitNot suitablejson.MatchCaseInsensitiveNames(true)
Slice/map nilnull[] / {}json.FormatNilSliceAsNull(true), json.FormatNilMapAsNull(true)
omitemptyDiscard zero value GoDiscard empty JSON values ​​( null, "", [], {})Use tag omitzero
time.DurationNanosecond numbersError, no default formatCheck out the official migration guide

One small pitfall: in v2, omitempty for int being 0 no longer discards the field, because the number 0 is not an "empty JSON value". If you mean to discard zero Go values, replace it with omitzero.

Step 3: How to use v2 to call the API?

The most common case in the backend is to call another API and then decode the response. This example calls the APIPedia national holiday endpoint, which uses standard envelope { is_success, message, data }. There are two v2 features in use:

  • json.UnmarshalRead reads directly from resp.Body without io.ReadAll.
  • Tag json:",unknown" on a field of type jsontext.Value holds the new field from the server, so no data is lost silently.
package main

import (
	"encoding/json/jsontext"
	"encoding/json/v2"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"
)

// Amplop standar APIPedia: { is_success, message, data }
type Envelope[T any] struct {
	IsSuccess bool   `json:"is_success"`
	Message   string `json:"message"`
	Data      T      `json:"data"`
}

type Libur struct {
	Tanggal    string `json:"tanggal"`
	Keterangan string `json:"keterangan"`
	Jenis      string `json:"jenis"`
	Hari       string `json:"hari"`
	SisaHari   int    `json:"sisa_hari"`
	// Field baru dari server disimpan di sini, tidak dibuang
	Lainnya jsontext.Value `json:",unknown"`
}

func main() {
	req, _ := http.NewRequest("GET", "https://api.apipedia.id/v1/libur/berikutnya", nil)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("APIPEDIA_KEY"))

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	var env Envelope[*Libur]
	if err := json.UnmarshalRead(resp.Body, &env); err != nil {
		log.Fatalf("decode gagal: %v", err)
	}
	if !env.IsSuccess || env.Data == nil {
		log.Fatalf("API error (%d): %s", resp.StatusCode, env.Message)
	}
	l := env.Data
	fmt.Printf("Libur berikutnya: %s, %s (%s), %d hari lagi\n",
		l.Keterangan, l.Hari, l.Tanggal, l.SisaHari)
	if len(l.Lainnya) > 0 {
		fmt.Println("Field tambahan dari server:", string(l.Lainnya))
	}
}

Run with the API key from console.apipedia.id:

APIPEDIA_KEY=apk_xxx go run .
# Libur berikutnya: Kelahiran Yesus Kristus (Natal), Jumat (2026-12-25), 49 hari lagi

The same pattern applies to any API. Generic Envelope[T] makes one envelope type reusable for all endpoints.

Step 4: How to create a strict webhook handler?

On the server side, v2 is most useful for endpoints that receive data from outside, for example payment webhooks. We want to reject strange payloads, not accept them silently.

Strict webhook handler flowchart with json/v2: incoming POST /orders request, limited to MaxBytesReader 1 MB, decoded with UnmarshalRead and RejectUnknownMembers, then replied with MarshalWrite; valid payload gets 200, while double amount, foreign is_admin field, and uppercase key ID get rejected 400

package main

import (
	"encoding/json/v2"
	"log"
	"net/http"
)

type Order struct {
	ID       string   `json:"id"`
	Amount   int64    `json:"amount"`
	Currency string   `json:"currency"`
	Note     string   `json:"note,omitzero"`
	Items    []string `json:"items"`
}

type Reply struct {
	OK     bool   `json:"ok"`
	Error  string `json:"error,omitzero"`
	Status string `json:"status,omitzero"`
}

func writeJSON(w http.ResponseWriter, code int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	json.MarshalWrite(w, v) // tulis langsung ke ResponseWriter
}

func handleOrder(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // batasi 1 MB
	var o Order
	err := json.UnmarshalRead(r.Body, &o,
		json.RejectUnknownMembers(true), // tolak field asing
	)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, Reply{Error: err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, Reply{OK: true, Status: "diterima " + o.ID})
}

func main() {
	http.HandleFunc("POST /orders", handleOrder)
	log.Println("listen :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

Test with four different payloads:

curl -s -X POST localhost:8080/orders \
  -d '{"id":"INV-1","amount":150000,"currency":"IDR","items":["kopi"]}'
# {"ok":true,"status":"diterima INV-1"}

curl -s -X POST localhost:8080/orders \
  -d '{"id":"INV-1","amount":150000,"amount":1,"currency":"IDR"}'
# {"ok":false,"error":"jsontext: duplicate object member name \"amount\""}

curl -s -X POST localhost:8080/orders \
  -d '{"id":"INV-2","amount":5000,"currency":"IDR","is_admin":true}'
# {"ok":false,"error":"json: cannot unmarshal JSON string into Go main.Order: unknown object member name \"is_admin\""}

curl -s -X POST localhost:8080/orders -d '{"ID":"INV-3","Amount":5000}'
# {"ok":false,"error":"json: cannot unmarshal JSON string into Go main.Order: unknown object member name \"ID\""}

Without a single line of manual validation, payloads with double amount, contraband is_admin fields, and uppercase keys are all rejected. For production, do not send raw err.Error() to clients; just note it in the log and reply to general messages.

Step 5: How to safely migrate legacy projects?

The Go team emphasized that v1 applications are not required to move. If you want to move, do it in stages:

  1. Upgrade to Go 1.27 first without changing imports. Run all tests. Package encoding/json is already running on a v2 machine, so this in itself already tests compatibility.
  2. Check tests that compare the output byte by byte (golden file, checksum, signature). There are reports that the output bytes for invalid UTF-8 changed between Go 1.26 and 1.27.
  3. Hot path benchmark. Performance results are not uniform (see following section).
  4. Move packets one by one to v2, starting from the endpoint that receives external input.
  5. If you need a long behavior, select the options one by one, not all of them at once.

Examples of using API v2 but with v1 behavior, or selecting only certain options:

import (
	jsonv1 "encoding/json"
	"encoding/json/jsontext"
	"encoding/json/v2"
)

// API v2, perilaku persis v1
b, err := json.Marshal(v, jsonv1.DefaultOptionsV1())

// API v2, hanya izinkan key ganda (sisanya tetap ketat)
err = json.Unmarshal(data, &v, jsontext.AllowDuplicateNames(true))

// Output deterministik (urutan key map tetap) + indentasi
out, err := json.Marshal(m, json.Deterministic(true), jsontext.WithIndent("  "))

If after upgrading there is strange behavior and you want to determine the cause of the new JSON engine, there is an emergency switch GOEXPERIMENT=nojsonv2 that restores the old v1 implementation:

GOEXPERIMENT=nojsonv2 go test ./...

Two notes from our testing and discussion in Go issue #79788: this switch fails to compile if your code or dependencies import encoding/json/v2 or jsontext ( a build constraints exclude all Go files error appears), and the working group calls it a short-term solution, not a permanent setting.

How fast is the v2 actually?

Go Blog's official claim: Unmarshal is much faster, with benchmarks showing up to a 10x improvement, while Marshal is roughly on par with the v1. Independent measurements paint a more varied picture:

  • Daniel Lemire (Go 1.27.0, one core) noted the unmarshal twitter.json went from 172 MB/s to 203 MB/s just by upgrading to Go 1.27 without code changes, but canada.json dropped from 128 MB/s to 106 MB/s. With straight API v2, unmarshal is 1.5x to 2.3x faster than the old implementation, while marshaling small structs is about 1.5x slower.
  • Huizhou92 (go1.27rc2, Apple M5) clocked UnmarshalSmall down 43.5%, but unmarshaled map[string]any up about 48% and MarshalSmall up 78.8%.

The conclusion: decoding to struct types is almost always profitable, while services that marshal a lot of small objects or decode to map[string]any must benchmark first. The Go team says further optimizations are planned for Go 1.28.

FAQs

Am I obliged to change encoding/json to v2?

No. According to the Go team, applications using v1 will remain supported indefinitely. v1 is now implemented on top of v2 with full compatibility options.

Can v1 and v2 be used together in one project?

Can. They are mutually compatible: v2 supports MarshalJSON v1-style methods, and v1 supports MarshalJSONTo v2-style methods. Just give it an import alias, for example jsonv1 "encoding/json".

Why are my fields empty after moving to v2?

Most likely because name matching is now case-sensitive. Payload {"NAME":"..."} no longer goes into tag json:"name". Correct the data source, or use option json.MatchCaseInsensitiveNames(true).

What is the difference between omitempty and omitzero in v2?

omitzero discards a field if its value is zero according to Go (0, empty string, nil). omitempty in v2 discards fields if the resulting JSON is empty: null, "", [], or {}. The number 0 is still written when using omitempty.

What does the jsontext package do?

jsontext handles pure JSON syntax without reflection: Encoder, Decoder, Token, and Value. Suitable for streaming large data or passing raw JSON. json.RawMessage in v1 is now aka jsontext.Value.

Source

Topics:

${c.about}

Tim APIPedia — Tim Engineering APIPedia

Tim engineering di balik APIPedia yang membangun dan memelihara API data Indonesia - ongkir, OCR KTP, data wilayah, BMKG, hingga server MCP - dan menulis panduan integrasinya dari pengalaman langsung.