How to Migrate to encoding/json/v2 in Go 1.27: A Practical Guide with Code Examples
By Tim APIPedia, 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.

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.

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) errorPay 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 tidyIf 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](https://supabase.apipedia.id/storage/v1/object/public/blog-media/posts/2026/11/cara-migrasi-go-json-v2-diagram-1-796bbc14.webp)
Summary of differences and return options
| Case | v1 | v2 (default) | Option for old behavior |
|---|---|---|---|
| Duplicate keys | Accepted, last value wins | Error | jsontext.AllowDuplicateNames(true) |
| UTF-8 is invalid | Replaced wildcards | Error | jsontext.AllowInvalidUTF8(true) |
| Key names are in different capitals | Still fit | Not suitable | json.MatchCaseInsensitiveNames(true) |
| Slice/map nil | null | [] / {} | json.FormatNilSliceAsNull(true), json.FormatNilMapAsNull(true) |
omitempty | Discard zero value Go | Discard empty JSON values ( null, "", [], {}) | Use tag omitzero |
time.Duration | Nanosecond numbers | Error, no default format | Check 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.UnmarshalReadreads directly fromresp.Bodywithoutio.ReadAll.- Tag
json:",unknown"on a field of typejsontext.Valueholds 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 lagiThe 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.

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:
- Upgrade to Go 1.27 first without changing imports. Run all tests. Package
encoding/jsonis already running on a v2 machine, so this in itself already tests compatibility. - 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.
- Hot path benchmark. Performance results are not uniform (see following section).
- Move packets one by one to v2, starting from the endpoint that receives external input.
- 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.jsonwent from 172 MB/s to 203 MB/s just by upgrading to Go 1.27 without code changes, butcanada.jsondropped 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
UnmarshalSmalldown 43.5%, but unmarshaledmap[string]anyup about 48% andMarshalSmallup 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
- The Go Blog: A new Go API for JSON (November 4, 2026)
- Linuxiac: Go 1.27 Released with Generic Methods, JSON v2, and Faster Memory Allocation
- Huizhou92: encoding/json/v2 Waited Five and a Half Years to Become Go's Default
- Daniel Lemire: The new Go JSON API, twice as fast, or 1.5x slower?
- DEV Community: 7 Go jsonv2 breaks I verified in Go 1.27
- GitHub golang/go #79788: GOEXPERIMENT=nojsonv2 and v2 packages
Topics: #golang #json #tutorial #backend #api
${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.