Go 1.27 の encoding/json/v2 入門 - v1 との挙動の違いと安全な移行手順

Go 1.27 の encoding/json/v2 入門 - v1 との挙動の違いと安全な移行手順

作成日:
読了:約24分
更新日:

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

2026年11月4日、Go の公式ブログに「A new Go API for JSON」が公開されました。Go 1.25 で実験的に入った encoding/json/v2 と encoding/json/jsontext が、2026年8月の Go 1.27 で正式な標準ライブラリになったことを改めて伝え、移行の考え方をまとめた記事です。あわせて公式の 移行ガイド も公開されています。

v2 は API の見た目こそ v1 とほとんど同じですが、デフォルトの挙動がかなり厳しくなっています。import を書き換えるだけでコンパイルは通るのに、出力される JSON が変わる、という種類の変更です。この記事では、go1.27.3 で実際にコードを動かした結果を載せながら、違いと移行手順を整理します。

NOTE

本文の実行結果は、golang.org/dl/go1.27.3 で導入した go1.27.3 darwin/arm64 で確認したものです。Go 1.27 を入れていない場合も、go install golang.org/dl/go1.27.3@latest && go1.27.3 download で既存の Go と別に試せます。

まず押さえること: v1 は無くならない

最初に結論を書くと、今すぐ移行しなくても困りません。移行ガイドの冒頭は「First things first: you don't have to!」で始まっていて、encoding/json は Go 1 の互換性の約束の対象なので、今後も動き続けると明言しています。

ただし、Go 1.27 では encoding/json(v1)の中身も変わりました。リリースノートによると、v1 は v2 の実装の上に v1 互換のオプションをすべて付けて動く形になっています。挙動は v1 のままですが、エラーメッセージの文言は変わることがあると書かれています。エラー文字列を文字列比較しているテストがあれば、Go 1.27 に上げた時点で落ちる可能性があります。

どうしても以前の実装に戻したい場合は、ビルド時に GOEXPERIMENT=nojsonv2 を付けると旧実装になります。ただし、この設定は将来のリリースで削除される予定です。

項目内容
新しいパッケージencoding/json/v2(意味の層)と encoding/json/jsontext(構文の層)
実験として導入Go 1.25(GOEXPERIMENT=jsonv2 が必要だった)
正式採用Go 1.27(2026年8月)
v1 の扱い残る。中身は v2 実装の上で v1 互換オプションを付けた形
旧実装に戻すGOEXPERIMENT=nojsonv2(将来削除予定)

パッケージの構成: jsontext と v2 の二層

v2 は2つのパッケージに分かれています。

  • encoding/json/jsontext: JSON の構文だけを扱う層です。リフレクションを使わず、トークン({ や文字列、数値)や値をストリームとして読み書きします。
  • encoding/json/v2: Go の値と JSON を対応させる層です。jsontext の上に作られています。

v1 の json.RawMessage は、Go 1.27 では jsontext.Value の型エイリアスになりました。v1 と v2 は相互に動くように作られていて、v2 の MarshalerTo を実装した型を v1 の json.Marshal に渡しても、ちゃんとその実装が呼ばれます。

主な関数は次の6つで、すべて最後に可変長の Options を受け取れます。

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

v1 では io.Writer に書き出すために json.NewEncoder(w).Encode(v) を使う必要がありましたが、v2 では MarshalWrite 一発です。オプションを Marshal や Unmarshal に直接渡せるのも v1 との大きな違いです(v1 では Decoder.DisallowUnknownFields のように Decoder 経由でしか設定できず、しかも独自の UnmarshalJSON の中には効きませんでした)。

v1 と v2 で挙動が変わる点

ここからが本題です。公式ブログが挙げている主な違いを、1つずつ実際に動かして確かめます。以降のコードでは、v1 を jsonv1 "encoding/json"、v2 を "encoding/json/v2"(パッケージ名は json)として import しています。

1. nil のスライスとマップが null ではなく [] と {} になる

移行ガイドが真っ先に例に出しているのがこれです。

type Pet struct {
	Name      string
	Nicknames []string
}
 
pets := []Pet{{Name: "Remi"}}
b1, _ := jsonv1.Marshal(pets)
b2, _ := json.Marshal(pets)

実行結果:

v1: [{"Name":"Remi","Nicknames":null}]
v2: [{"Name":"Remi","Nicknames":[]}]

新しく作るプログラムなら [] の方が扱いやすいことが多いのですが、既存の API でフロントエンドが null を前提に if (data.nicknames === null) と書いていれば、それだけで動きが変わります。v1 と同じにしたいフィールドが残るなら、json.FormatNilSliceAsNull(true)(マップは json.FormatNilMapAsNull(true))を渡します。

2. フィールド名の大文字小文字を区別する

v1 は JSON のキーと構造体のフィールド名を、大文字小文字を無視して照合していました。v2 は区別します。

type User struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}
 
in := []byte(`{"ID":1,"Name":"alice"}`)
var u1, u2, u3 User
jsonv1.Unmarshal(in, &u1)
json.Unmarshal(in, &u2)
json.Unmarshal(in, &u3, json.MatchCaseInsensitiveNames(true))

実行結果(ID の値だけ表示):

v1: 1
v2: 0   (エラーにはならない)
v2 + MatchCaseInsensitiveNames(true): 1

注意したいのは、v2 で一致しなかったキーがエラーにならず黙って無視されることです。未知のキーを無視するのは v1 と同じデフォルトなので、相手が "ID" で送ってきていた場合、値がゼロのまま処理が進みます。フィールド単位で切り替えたい場合は、タグに case:ignore(大文字小文字に加えて - と _ も無視)か case:strict を書けます。

未知のキーをエラーにしたい場合は json.RejectUnknownMembers(true) を渡します。

json: cannot unmarshal JSON string into Go main.User: unknown object member name "role"

3. 重複したキーと不正な UTF-8 をエラーにする

JSON の仕様(RFC 8259)は文字列が正しい UTF-8 であることを求めていますが、v1 は不正なバイトを黙って置換文字(U+FFFD)に変えていました。重複したキーも、v1 は後の値で上書きして受け入れていました。

var d1, d2 User
jsonv1.Unmarshal([]byte(`{"id":1,"id":2}`), &d1)
json.Unmarshal([]byte(`{"id":1,"id":2}`), &d2)
 
bad := []byte("{\"name\":\"\xff\"}")
var x1, x2 User
jsonv1.Unmarshal(bad, &x1)
json.Unmarshal(bad, &x2)

実行結果:

v1 重複キー: err=<nil>, id=2
v2 重複キー: jsontext: duplicate object member name "id"
v1 不正UTF-8: err=<nil>, name="�"
v2 不正UTF-8: jsontext: invalid UTF-8 within "/name" after offset 9

重複キーは、受け取る側のパーサーによって「最初の値を採る」「最後の値を採る」が分かれるため、検証する層と処理する層で別の値を見てしまう脆弱性の原因になります。v2 がデフォルトで拒否するのはこのためです。どうしても受け入れる必要がある場合は jsontext.AllowDuplicateNames(true)、jsontext.AllowInvalidUTF8(true) で緩められます。UTF-8 の仕組みそのものは「文字コード入門」で解説しています。

4. omitempty の意味が変わり、数値の 0 は省略されなくなる

これは見落としやすい変更です。v2 の omitempty は「JSON にしたときに null、""、[]、{} になる値を省略する」という定義に変わりました。数値の 0 や false は JSON としては空ではないので、v2 では省略されません。

type User struct {
	ID    int       `json:"id"`
	Name  string    `json:"name"`
	Email string    `json:"email,omitempty"`
	Age   int       `json:"age,omitempty"`
	Since time.Time `json:"since,omitzero"`
}
 
ub1, _ := jsonv1.Marshal(User{ID: 1, Name: "bob"})
ub2, _ := json.Marshal(User{ID: 1, Name: "bob"})

実行結果:

v1: {"id":1,"name":"bob"}
v2: {"id":1,"name":"bob","age":0}

Go の型として「ゼロ値なら省く」と書きたい場合は、omitzero を使います。omitzero は Go 1.24 で v1 にも入ったタグで、IsZero() bool メソッドがあればそれで判定するため、time.Time のゼロ値もきちんと省略されます(上の例の since が両方で消えているのはこのためです)。既存の構造体で数値や bool に omitempty を付けている箇所は、omitzero へ書き換えるのが素直です。

5. time.Duration は表現を決めないとエラーになる

v1 は time.Duration をナノ秒の整数として出力していました。v2 には決まった表現がなく、そのままではエラーになります。

type Job struct {
	Timeout time.Duration `json:"timeout"`
}
_, err := json.Marshal(Job{Timeout: 3 * time.Second})

実行結果:

v1: {"timeout":3000000000}
v2: json: cannot marshal from Go time.Duration within "/timeout": no default representation

3000000000 が3秒なのかどうかは受け取り側から分からないので、「暗黙に決めない」という判断です。Go 1.25 の実験版では format:nano のようなタグで表現を選べましたが、後述するとおり format タグは Go 1.27 の正式版から削除されています。正式版では、次に紹介する MarshalFunc で型ごとの変換を渡すか、"3s" のような文字列にする独自型を用意します。

6. 後ろに余計なデータがあるとエラーにする

v1 の json.NewDecoder(r).Decode(v) は、最初の値を読んだところで止まるため、後ろにゴミが付いていても気づけませんでした。v2 の UnmarshalRead は入力全体が1つの値であることを確かめます。

v1 Decoder(入力: {"id":1} xyz): err=<nil>, id=1
v2 UnmarshalRead(入力: {"id":1} {"id":2}): jsontext: invalid character '{' after top-level value after offset 9

改行区切りの JSON(JSON Lines)のように複数の値を順に読みたい場合は、jsontext.NewDecoder を作って json.UnmarshalDecode を繰り返し呼びます。

その他の違い

公式ドキュメントに書かれている違いを、表にまとめておきます。

項目v1v2v1 の挙動に戻すオプション
nil スライス・マップnull[] / {}FormatNilSliceAsNull / FormatNilMapAsNull
キーの照合大文字小文字を無視区別するMatchCaseInsensitiveNames
重複キー後勝ちで受け入れエラーjsontext.AllowDuplicateNames
不正な UTF-8U+FFFD に置換エラーjsontext.AllowInvalidUTF8
omitemptyGo のゼロ値に近い判定JSON で空になる値だけ数値・bool は omitzero に書き換え
time.Durationナノ秒の整数エラーMarshalFunc などで表現を決める
構造体タグの誤り実行時は無視実行時エラータグを直す
HTML 用のエスケープ< > & を \u003c などにするしないjsontext.EscapeForHTML(true)

「構造体タグの誤り」は、移行ガイドが例に出している「string タグを構造体などの対象外の型に付けている」ケースです。v1 では黙って無視されていたので、付けても意味がなかったタグを消すのが正しい直し方です。

最後の「HTML 用のエスケープ」も、手元で確かめると次のようになりました。

a, _ := jsonv1.Marshal("<b>&")
b, _ := json.Marshal("<b>&")
c, _ := json.Marshal("<b>&", jsontext.EscapeForHTML(true))
// a: "\u003cb\u003e\u0026"
// b: "<b>&"
// c: "\u003cb\u003e\u0026"

JSON を <script> の中に直接埋め込んでいるテンプレートがあると、v2 に切り替えた時点で </script> を含む文字列が HTML のパーサーに誤解される余地が生まれます。こうした使い方をしている箇所では EscapeForHTML(true) を付けるか、HTML 側のテンプレートエンジン(Go なら html/template)に任せてください。理由は「XSS(クロスサイトスクリプティング)入門」で説明しています。

v2 で増えた便利な機能

挙動の違いだけでなく、v2 には v1 では書きにくかったことを簡単にする機能が入っています。

型ごとの変換を呼び出し側から差し込む

json.WithMarshalers と json.MarshalFunc を使うと、自分で定義していない型の出力方法を呼び出し側から変えられます。v1 では、外部パッケージの型の出力を変えるにはラッパー型を作るしかありませんでした。

m := json.WithMarshalers(json.MarshalFunc(func(t time.Time) ([]byte, error) {
	return []byte(fmt.Sprintf("%q", t.Format("2006-01-02"))), nil
}))
b, _ := json.Marshal(User{ID: 2, Name: "carol", Since: time.Date(2026, 11, 5, 9, 0, 0, 0, time.UTC)}, m)

実行結果:

{"id":2,"name":"carol","age":0,"since":"2026-11-05"}

呼び出し側から渡した関数は、型自身の MarshalJSON より優先されます。デコード側には WithUnmarshalers と UnmarshalFunc があります。

マップのキー順を固定する

json.Deterministic(true) を渡すと、マップのキーが常に同じ順で出力されます。テストのスナップショットや、署名・ハッシュを取る前の正規化に便利です。

json.MarshalWrite(os.Stdout, map[string]int{"b": 2, "a": 1}, json.Deterministic(true))
// {"a":1,"b":2}

トークン単位でストリーム処理する

巨大な JSON を全部メモリに載せずに処理したい場合は、jsontext の Decoder でトークンを1つずつ読めます。

dec := jsontext.NewDecoder(strings.NewReader(`{"items":[1,2,3]}`))
for {
	tok, err := dec.ReadToken()
	if err != nil {
		break
	}
	fmt.Print(" ", tok.Kind().String())
}
// { string [ number number number ] }

独自型の変換も、v2 では MarshalJSONTo(*jsontext.Encoder) / UnmarshalJSONFrom(*jsontext.Decoder) という Encoder・Decoder を直接受け取る形(MarshalerTo / UnmarshalerFrom)で書けます。v1 の MarshalJSON は毎回 []byte を作って返し、それを外側でもう一度検証していたため、入れ子になると処理量が膨らむ問題がありました。

移行の進め方

移行ガイドは、リスクの許容度に応じて3つの進め方を示しています。

進め方向いているケースやること
一括で切り替える小さなツール、問題が起きても影響が小さいものimport を v2 に変え、テストが通れば終わり
オプションを1つずつ外す複雑なアプリ、慎重に進めたいものDefaultOptionsV1() 付きの v2 に置き換え、互換オプションを1つずつ外す
jsonsplit で本番の差分を見る本番で動いているサーバーv1 と v2 の両方を実行して差分を記録し、v1 の結果を返す

オプションを1つずつ外す

jsonv1.DefaultOptionsV1() を渡した v2 の呼び出しは、v1 とまったく同じ挙動になります。移行ガイドによると、v1 の json.Marshal 自体がまさにこの形で実装されています。オプションは後に書いたものが優先されるので、次のように v1 の挙動から1項目ずつ v2 に寄せられます。

import (
	jsonv1 "encoding/json"
	"encoding/json/v2"
)
 
// v1 と同じ挙動
b, err := json.Marshal(v, jsonv1.DefaultOptionsV1())
 
// v1 の挙動のまま、nil スライスだけ [] にする
b, err = json.Marshal(v, jsonv1.DefaultOptionsV1(), json.FormatNilSliceAsNull(false))

最初の一歩(DefaultOptionsV1() 付きの v2 への置き換え)は挙動が変わらないので安全に入れられ、その後は1項目ずつテストしながら外していけます。何かが壊れたときに、どの違いが原因かを二分探索で絞り込む用途にも使えます。

jsonsplit で本番の差分を検出する

本番で動いているサーバーには、Go チームが用意した github.com/go-json-experiment/jsonsplit が使えます。jsonsplit.Marshal は v1 と v2 の両方で処理して差分があれば報告し、呼び出し元には v1 の結果を返します。動作を変えずに、本番のトラフィックで違いだけを集められます。

func init() {
	// v1 と v2 の両方を呼び、返すのは v1 の結果
	jsonsplit.GlobalCodec.SetMarshalCallMode(jsonsplit.CallBothButReturnV1)
	// 差分の原因になったオプションを自動で特定する
	jsonsplit.GlobalCodec.AutoDetectOptions = true
	jsonsplit.GlobalCodec.ReportDifference = func(d jsonsplit.Difference) {
		log.Printf("json v1/v2 difference: %v", d)
	}
}

移行ガイドの例では、差分と一緒に "Options":["jsonv2.FormatNilSliceAsNull"] のように原因のオプション名まで報告されます。両方で処理する分、マーシャルのコストはおよそ2倍になる(AutoDetectOptions を付けるとさらに増える)ため、SetMarshalCallRatio で一部の呼び出しだけを検査する設定も用意されています。

手順は、jsonsplit に置き換えて差分を監視し、差分が出た箇所はオプションや型を調整して出力をそろえ、差分が出なくなったら OnlyCallV2 か CallBothButReturnV2 に切り替え、最後に jsonsplit を外して encoding/json/v2 を直接使う、という流れです。

Go 1.25 の実験版を試していた人への注意

Go 1.25 で GOEXPERIMENT=jsonv2 を付けて実験版を使っていたコードは、Go 1.27 でそのまま動かない可能性があります。Go 1.27 のリリースノートには、実験期間中に次の変更があったと書かれています。

  • format タグオプションを削除(format:RFC3339 のような書き方)
  • unknown タグオプションと DiscardUnknownMembers オプションを削除
  • SkipFunc を削除
  • inline タグオプションを embed に改名
  • string タグオプションと MatchCaseInsensitiveNames の挙動を変更
  • jsontext の数値トークンを取り出すメソッドがエラーも返すように変更

インターネット上の v2 の解説記事には、実験版の時期に書かれたものも多くあります。format: や inline を使っている例を見かけたら、正式版では使えないものと考えてください。

速度はどう変わったか

公式ブログは、Marshal は v1 とおおむね同じで、Unmarshal は大幅に速くなり、最大で10倍程度の改善があると書いています。MarshalerTo / UnmarshalerFrom に書き換えるとさらに速くなり、Kubernetes での事例では桁違いの改善があったそうです。Go 1.28 でもさらに改善する予定と書かれています。

一方、Daniel Lemire 氏が Go 1.27.0 で測ったベンチマークでは、any へのデコードは v2 が元の実装より1.5〜2.3倍速かった一方、型付きの構造体のマーシャルは元の実装の方が速く、約1.5倍遅いという結果も出ています。1人の筆者が自分の環境とデータで測った結果ではありますが、「全部が速くなる」とは限らないことは覚えておいた方がよさそうです。速度を理由に移行する場合は、自分のデータでベンチマークを取ってから判断するのが確実です。

まとめ

  • Go 1.27 で encoding/json/v2 と encoding/json/jsontext が正式採用された。v1 は残り、移行は必須ではない
  • ただし v1 の中身も v2 実装に変わったので、エラー文言の比較などは Go 1.27 に上げた時点で影響が出得る
  • v2 は nil スライスを [] にする、キーの大文字小文字を区別する、重複キーと不正な UTF-8 を拒否する、数値の 0 を omitempty で省略しない、time.Duration に既定の表現がない
  • 移行は「DefaultOptionsV1() 付きの v2 に置き換えて1項目ずつ外す」か、本番なら jsonsplit で差分を観測してから進める
  • 実験版の format タグや inline は正式版で削除・改名されている

いちばん気をつけたいのは、コンパイルが通るのに出力が変わる点と、キーの不一致がエラーにならず黙ってゼロ値になる点です。テストで JSON の出力そのものを比較していない箇所は、移行前に比較するテストを足しておくと安心です。

Go 1.27 には、ほかにも UUID を扱う標準パッケージ uuid が入っています。ID の形式そのものについては「UUID と ULID の選び方」、大きな整数を JSON でやり取りするときに精度が落ちる理由は「浮動小数点数 入門」で解説しています。

参考リンク

Raft合意アルゴリズム入門 - リーダー選出とログ複製を実装で理解する

Raft合意アルゴリズム入門 - リーダー選出とログ複製を実装で理解する

約36分

分散合意アルゴリズムRaftを、OngaroとOusterhoutの原論文を一次ソースに整理します。単一障害点を消すための状態機械複製という出発点、Paxosの難解さに対して理解しやすさを設計目標に据えた経緯、Follower・Candidate・Leaderの3状態とtermという論理時計、election timeoutのランダム化がsplit voteを解消する仕組み、AppendEntriesとcommitIndex、Log Matching PropertyとElection Restriction、2f+1台でf台の故障に耐えるクォーラムの算術、joint consensusとスナップショット、etcd・Consul・TiKV・CockroachDBでの実装、そしてReadIndexとlease readまで、実際に動かして出力を確認したPythonシミュレータで解説します。

ロードバランシングのアルゴリズム入門 - ラウンドロビンから最少コネクション・P2Cまで

ロードバランシングのアルゴリズム入門 - ラウンドロビンから最少コネクション・P2Cまで

約14分

負荷分散(ロードバランシング)の基礎を実務目線で整理します。L4とL7の違い、ラウンドロビン・加重ラウンドロビン・最少コネクション・最短応答時間・IPハッシュ・Power of Two Choices・Maglevといった主要アルゴリズムの挙動と向き不向き、nginx/HAProxy/Envoyの既定、ヘルスチェックやスティッキーセッションまで、公式ドキュメントを出典にまとめます。

ブルームフィルタとは - 少ないメモリで「たぶん在る・確実に無い」を判定する確率的データ構造

ブルームフィルタとは - 少ないメモリで「たぶん在る・確実に無い」を判定する確率的データ構造

約15分

ブルームフィルタ(Bloom filter)を基礎から解説します。偽陽性はあるが偽陰性は無い性質、mビット配列とk個のハッシュ関数によるadd/query、偽陽性率と最適kの導出、O(k)の計算量、Cassandra/RocksDBなどの実運用、Counting/Scalable/Cuckooといった変種までを一次ソースで整理します。

楽天市場のおすすめPR / 楽天アフィリエイト