Cara Migrasi ke encoding/json/v2 di Go 1.27: Panduan Praktis dengan Contoh Kode

Oleh , Tim Engineering APIPedia · Terbit · 9 menit baca

Go Blog resmi merilis panduan JSON API baru. Pelajari beda v1 dan v2, buat klien API dan webhook ketat, lalu migrasi aman di Go 1.27.

Sampul artikel APIPedia: Migrasi ke json/v2 di Go 1.27, dengan kartu fakta dari Go Blog 4 November 2026 bahwa unmarshal hingga 10 kali lebih cepat dan key duplikat menjadi error secara default

encoding/json/v2 adalah paket JSON baru di pustaka standar Go yang stabil sejak Go 1.27, lengkap dengan paket pendamping encoding/json/jsontext. Pada 4 November 2026, tim Go menerbitkan artikel "A new Go API for JSON" beserta panduan migrasinya. Ini penting karena v2 lebih ketat secara default dan proses unmarshal-nya jauh lebih cepat.

Panduan ini praktis: kita pasang Go 1.27, melihat perbedaan perilaku v1 dan v2 dengan kode yang bisa langsung dijalankan, membuat klien API dan handler webhook yang ketat, lalu menyusun langkah migrasi yang aman untuk proyek lama. Semua contoh sudah kami uji dengan go1.27.0 linux/amd64.

Apa yang sebenarnya berubah di Go 1.27?

Go 1.27 dirilis 19 Agustus 2026. Di versi ini encoding/json/v2 dan encoding/json/jsontext tidak lagi berstatus eksperimen. Paket lama encoding/json tetap ada dan tetap didukung tanpa batas waktu, tetapi kini berjalan di atas mesin v2 dengan seluruh opsi kompatibilitas v1 dinyalakan.

Jadi ada dua kabar sekaligus:

  • Kode lama tetap jalan. Anda tidak wajib mengganti import.
  • Kode baru sebaiknya memakai v2. Default-nya lebih aman: UTF-8 tidak valid dan nama key ganda langsung ditolak.

Kronologi encoding/json/v2: repo eksperimen go-json-experiment dibuat 19 Februari 2021, proposal 71497 pada 31 Januari 2025, tersedia di Go 1.25 lewat GOEXPERIMENT=jsonv2 pada Agustus 2025, proposal diterima Mei 2026, Go 1.27 rilis 19 Agustus 2026 dengan v2 stabil, dan artikel Go Blog A new Go API for JSON terbit 4 November 2026

API inti v2 terlihat seperti ini (dikutip dari 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

Perhatikan parameter opts ...Options. Di v1, mengatur perilaku decoder berarti membuat json.Decoder lalu memanggil method satu per satu. Di v2, opsi cukup ditempel di argumen terakhir.

Langkah 1: Bagaimana menyiapkan Go 1.27?

Cek versi Go Anda, lalu naikkan versi di go.mod proyek:

go version
# go version go1.27.0 linux/amd64

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

Kalau toolchain lokal masih lama, unduh dari go.dev/dl. Tidak perlu GOEXPERIMENT apa pun lagi: paket v2 langsung tersedia.

Langkah 2: Apa beda perilaku v1 dan v2 dalam praktik?

Cara tercepat memahami v2 adalah membandingkannya langsung. Simpan kode berikut sebagai 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))
}

Jalankan dengan go run .. Ini output asli dari uji kami:

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":[]}

Baris pertama yang paling penting. Di v1, payload dengan "name" ganda diterima dan nilai terakhir yang menang. Kalau satu komponen sistem membaca key pertama dan komponen lain membaca key terakhir, penyerang bisa menyelundupkan nilai. Celah semacam ini pernah terjadi di CouchDB (CVE-2017-12635). v2 menolaknya sejak awal.

Tabel perbandingan perilaku encoding/json v1 dan v2: key duplikat diterima di v1 tetapi error di v2, UTF-8 tidak valid diganti karakter pengganti di v1 tetapi error di v2, pencocokan nama key case-insensitive di v1 dan case-sensitive di v2, slice dan map nil menjadi null di v1 dan [] atau {} di v2, makna omitempty berubah, time.Duration error di v2, beserta opsi kompatibel untuk tiap kasus

Ringkasan perbedaan dan opsi pengembaliannya

Kasusv1v2 (default)Opsi untuk perilaku lama
Key duplikatDiterima, nilai terakhir menangErrorjsontext.AllowDuplicateNames(true)
UTF-8 tidak validDiganti karakter penggantiErrorjsontext.AllowInvalidUTF8(true)
Nama key beda kapitalTetap cocokTidak cocokjson.MatchCaseInsensitiveNames(true)
Slice/map nilnull[] / {}json.FormatNilSliceAsNull(true), json.FormatNilMapAsNull(true)
omitemptyBuang nilai nol GoBuang nilai JSON kosong (null, "", [], {})Pakai tag omitzero
time.DurationAngka nanodetikError, tidak ada format defaultLihat panduan migrasi resmi

Satu jebakan kecil: di v2, omitempty untuk int bernilai 0 tidak lagi membuang field, karena angka 0 bukan "nilai JSON kosong". Kalau maksud Anda membuang nilai nol Go, ganti dengan omitzero.

Langkah 3: Bagaimana memakai v2 untuk memanggil API?

Kasus paling umum di backend adalah memanggil API lain lalu men-decode respons. Contoh ini memanggil endpoint hari libur nasional APIPedia, yang memakai amplop standar { is_success, message, data }. Ada dua fitur v2 yang terpakai:

  • json.UnmarshalRead membaca langsung dari resp.Body tanpa io.ReadAll.
  • Tag json:",unknown" pada field bertipe jsontext.Value menampung field baru dari server, jadi tidak ada data yang hilang diam-diam.
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))
	}
}

Jalankan dengan API key dari console.apipedia.id:

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

Pola yang sama berlaku untuk API apa pun. Generic Envelope[T] membuat satu tipe amplop bisa dipakai ulang untuk semua endpoint.

Langkah 4: Bagaimana membuat handler webhook yang ketat?

Di sisi server, v2 paling terasa manfaatnya untuk endpoint yang menerima data dari luar, misalnya webhook pembayaran. Kita ingin menolak payload yang aneh, bukan menerimanya diam-diam.

Diagram alur handler webhook ketat dengan json/v2: request POST /orders masuk, dibatasi MaxBytesReader 1 MB, di-decode dengan UnmarshalRead dan RejectUnknownMembers, lalu dibalas dengan MarshalWrite; payload valid mendapat 200, sedangkan amount ganda, field is_admin asing, dan key ID berhuruf besar ditolak 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))
}

Uji dengan empat payload berbeda:

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\""}

Tanpa satu baris validasi manual, payload dengan amount ganda, field is_admin selundupan, dan key berhuruf besar semuanya ditolak. Untuk produksi, jangan kirim err.Error() mentah ke klien; cukup catat di log dan balas pesan umum.

Langkah 5: Bagaimana migrasi proyek lama dengan aman?

Tim Go menegaskan aplikasi v1 tidak wajib pindah. Kalau ingin pindah, lakukan bertahap:

  1. Naikkan ke Go 1.27 dulu tanpa mengubah import. Jalankan seluruh test. Paket encoding/json sudah berjalan di mesin v2, jadi ini sendiri sudah menguji kompatibilitas.
  2. Periksa test yang membandingkan output byte demi byte (golden file, checksum, tanda tangan). Ada laporan bahwa byte keluaran untuk UTF-8 tidak valid berubah antara Go 1.26 dan 1.27.
  3. Benchmark jalur panas. Hasil performa tidak seragam (lihat bagian berikut).
  4. Pindahkan paket satu per satu ke v2, mulai dari endpoint yang menerima input dari luar.
  5. Kalau butuh perilaku lama, pilih opsinya satu per satu, jangan langsung semuanya.

Contoh memakai API v2 tetapi dengan perilaku v1, atau memilih opsi tertentu saja:

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("  "))

Kalau setelah naik versi ada perilaku aneh dan Anda ingin memastikan penyebabnya mesin JSON baru, ada saklar darurat GOEXPERIMENT=nojsonv2 yang mengembalikan implementasi v1 lama:

GOEXPERIMENT=nojsonv2 go test ./...

Dua catatan dari uji kami dan diskusi di issue Go #79788: saklar ini gagal dikompilasi bila kode Anda atau dependensi meng-import encoding/json/v2 atau jsontext (muncul error build constraints exclude all Go files), dan working group menyebutnya solusi jangka pendek, bukan pengaturan permanen.

Seberapa cepat v2 sebenarnya?

Klaim resmi Go Blog: Unmarshal jauh lebih cepat, dengan benchmark menunjukkan peningkatan hingga 10x, sementara Marshal kira-kira setara v1. Pengukuran independen memberi gambaran lebih bervariasi:

  • Daniel Lemire (Go 1.27.0, satu core) mencatat unmarshal twitter.json naik dari 172 MB/s ke 203 MB/s hanya dengan upgrade ke Go 1.27 tanpa ubah kode, tetapi canada.json turun dari 128 MB/s ke 106 MB/s. Dengan API v2 langsung, unmarshal 1,5x sampai 2,3x lebih cepat dibanding implementasi lama, sedangkan marshal struct kecil sekitar 1,5x lebih lambat.
  • Huizhou92 (go1.27rc2, Apple M5) mencatat waktu UnmarshalSmall turun 43,5%, tetapi unmarshal ke map[string]any naik sekitar 48% dan MarshalSmall naik 78,8%.

Kesimpulannya: decode ke struct bertipe hampir selalu untung, sedangkan layanan yang banyak me-marshal objek kecil atau decode ke map[string]any wajib benchmark dulu. Tim Go menyebut optimasi lanjutan direncanakan untuk Go 1.28.

FAQ

Apakah saya wajib mengganti encoding/json ke v2?

Tidak. Menurut tim Go, aplikasi yang memakai v1 tetap didukung tanpa batas waktu. v1 kini diimplementasikan di atas v2 dengan opsi kompatibilitas penuh.

Apakah v1 dan v2 bisa dipakai bersamaan dalam satu proyek?

Bisa. Keduanya saling kompatibel: v2 mendukung method MarshalJSON gaya v1, dan v1 mendukung MarshalJSONTo gaya v2. Cukup beri alias import, misalnya jsonv1 "encoding/json".

Kenapa field saya kosong setelah pindah ke v2?

Kemungkinan besar karena pencocokan nama kini case-sensitive. Payload {"NAME":"..."} tidak lagi masuk ke tag json:"name". Perbaiki sumber datanya, atau pakai opsi json.MatchCaseInsensitiveNames(true).

Apa beda omitempty dan omitzero di v2?

omitzero membuang field bila nilainya nol menurut Go (0, string kosong, nil). omitempty di v2 membuang field bila hasil JSON-nya kosong: null, "", [], atau {}. Angka 0 tetap ditulis bila memakai omitempty.

Apa fungsi paket jsontext?

jsontext menangani sintaks JSON murni tanpa reflection: Encoder, Decoder, Token, dan Value. Cocok untuk streaming data besar atau meneruskan JSON mentah. json.RawMessage di v1 kini alias dari jsontext.Value.

Sumber

Topik:

Tentang penulis

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.