Skip to content
Go
encoding/json — JSON 序列化实战与陷阱

encoding/json — JSON 序列化实战与陷阱

encoding/json 大概是 Go 里调用频率最高的标准库包:HTTP API 的请求响应、配置文件、消息队列载荷、日志结构化字段,几乎都走它。它的设计哲学是反射驱动、零注册——不需要像 protobuf 那样先定义 schema,给个结构体就能编解码,代价是运行时反射开销和一些"看起来生效其实没生效"的标签写法。

Go 1.27 对这个包做了一次底层替换:encoding/json 的实现换成了 encoding/json/v2,对外 API 与行为基本保持兼容,但错误文本、部分边界行为会变。官方发布说明原文:

The encoding/json package is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ. […] Marshal performance is broadly at parity […], while unmarshal performance is significantly faster. Users who encounter compatibility problems […] may disable it by setting GOEXPERIMENT=nojsonv2 at build time.

所以本文的组织方式是:先把日常用法和陷阱讲清楚(这些行为 v1/v2 一致或有明确差异会标注),再专门用一节梳理 v2 带来的变化与迁移策略。所有对比结论都在 go1.27.0 上实测过,引用的输出是程序真实打印结果。


1. 基础:标签语法与实际生效范围

type User struct {
    ID        int64      `json:"id"`
    Name      string     `json:"name,omitempty"`
    Email     string     `json:"-"`               // 永不参与编解码
    CreatedAt time.Time  `json:"created_at,omitzero"`
    Tags      []string   `json:"tags"`
    secret    string     // 非导出字段:直接忽略,不报错
}

标签形式是 json:"名称,选项1,选项2"。Go 1.27 认可的选项:omitempty、omitzero、string、case:ignore、embed,以及被解析但实测不可用的 format。

注意:format 选项在 1.27 的 encoding/json 和 encoding/json/v2 上都会让整次操作失败,go doc 的选项列表里能看到它,实现却尚未开放。实测:

type T struct { A time.Time `json:"a,format:unix"` }
json.Marshal(T{})
// json: unsupported value: Go struct field A has unsupported `format` tag option

需要自定义时间格式,仍然只能自己实现 MarshalJSON/UnmarshalJSON(见 §6)。

几条反复踩到的基础规则:

  • 只有导出字段会被编码。非导出字段静默跳过,不会报错;Marshal 时无法通过 json:"X" 强行导出。想让某个非导出字段参与编解码,只能给它写 MarshalJSON。
  • 字段名匹配大小写不敏感(v1 行为):JSON 里的 NAME、naMe 都会落到 Name 字段。这是历史包袱,v2 收紧了(见 §2)。反过来,Marshal 输出永远使用标签名或 Go 字段名,不会变。
  • 匿名字段(嵌入结构体)的字段会被提升到外层,不需要任何标签。这是隐式行为;一旦你给嵌入字段显式写了标签名,就当作普通具名字段处理,不再提升。
  • json:"-" 表示跳过,不是键名 -。想让 - 成为真实键名,go doc 给的两种写法实测都不能依赖:json:"-," 在 v1 兼容层可用(输出 {"-":"k1"}),但底层 v2 对同一标签报 Go struct field K1 has malformed json tag: invalid trailing ',' character;文档推荐的单引号形式 json:"'-'" 两个引擎都不接受(v1 静默忽略标签、键名回落为字段名,v2 报 invalid character '\'' at start of option)。真有这种畸形键名需求,改用 map[string]any 或 json.RawMessage 手工拼。

Marshal 与 MarshalIndent:

b, _ := json.Marshal(u)                       // 紧凑输出,无换行
bi, _ := json.MarshalIndent(u, "", "  ")      // 缩进输出

生产里更推荐 json.NewEncoder(w) + SetIndent,原因在 §7(流式、少一次内存拷贝、注意尾部换行)。


2. JSON v2 时代:行为差异与迁移

2.1 三件套包结构

包 定位
encoding/json 兼容层。API 不变(Marshal(v any) 不接选项),1.27 起底层是 v2
encoding/json/v2 新实现。Marshal/Unmarshal + 变参选项,另有 MarshalWrite/MarshalEncode/UnmarshalRead/UnmarshalDecode 直连 io 的入口
encoding/json/jsontext 只管语法层(token/value 流),不做 Go 类型映射;json.RawMessage 现在是它的 Value 别名

关键的选项体系:encoding/json 导出了一批 Options 构造器(DefaultOptionsV1、OmitEmptyWithLegacySemantics、MergeWithLegacySemantics、FormatDurationAsNano、ReportErrorsWithLegacySemantics 等),但 v1 的 Marshal/Unmarshal 签名里没有选项参数——要精细控制 v1/v2 兼容度,必须显式 import v2,因为 Options 是同一类型别名,可以跨包传。实测:

type D struct { Dur time.Duration `json:"dur"` }

jsonv2.Marshal(D{Dur: time.Second})
// {"dur"(json: cannot marshal from Go time.Duration within "/dur": no default representation)

jsonv2.Marshal(D{Dur: time.Second}, json.FormatDurationAsNano(true))
// {"dur":1000000000}

2.2 实测差异清单

以下都是同一个程序在 go1.27.0 上分别调用 encoding/json 与 encoding/json/v2 的输出对照,值得逐条过一遍——迁移时的问题基本都在这里。

场景 v1 API(1.27 默认) v2 API
HTML 转义 {"Content":"<script>...&"} 原样输出 <script>...&
nil slice / nil map null [] / {}
map 键顺序 排序,确定性 非确定(两次运行顺序不同),Deterministic(true) 可恢复排序
非法 UTF-8 字符串 成功(坏字节替换为 U+FFFD) 报错 jsontext: invalid UTF-8 within "/S" after offset 5
JSON 对象重复键 成功,后者覆盖前者 报错 jsontext: duplicate object member name "a"
大小写不同的键 {"NAME":"upper"} → Name="upper" Name=""(除非标签写 case:ignore,实测 v1/v2 都生效)
string 选项用在 bool 忽略 报错 invalid use of 'string' tag option
[4]byte [1,2,3,4] "AQIDBA=="(数组当 base64)
time.Duration 1000000000 报错,无默认表示
map 键类型有 MarshalJSON 报错 object member name must be a string 正常,{"m":{"addr":2}}
畸形标签 json:"'-'" / json:"-," 前者静默忽略(键名回落为字段名),后者输出 {"-":"..."} 两者都报错:invalid character '\'' at start of option / invalid trailing ',' character
JSON 数组短于 Go 数组 逐元素写入,剩余清零 报错 too few array elements,且该字段完全不被改写
null 解码到非 nil 指针 指针置为 nil 保留原值(合并语义)
未知字段 忽略 忽略;RejectUnknownMembers(true) 报错

其中三条最容易在上线后暴露:

  1. time.Duration 直接编不过去了。v2 认为纳秒整数是"实现细节"而不是合法 JSON 表示。若接口里必须输出数字纳秒,用 int64 字段自己 d.Nanoseconds();若可接受字符串,写 "1s"(自定义类型包一层)。
  2. nil slice → []。前端拿到空数组而非 null 通常是好事,但如果对端用 null 区分"字段缺失"和"空集合",这就是行为变更。要 v1 语义:jsonv2.Marshal(v, json.FormatBytesWithLegacySemantics(true)) 之类的 legacy 开关不覆盖它,实测最稳的办法是标签 omitempty/omitzero 或显式初始化。
  3. map 顺序不再确定。如果输出被用于 diff、缓存 key、签名、快照测试,必须 Deterministic(true),或者干脆改用 slice。

2.3 迁移策略

  • 短期求稳:构建时加 GOEXPERIMENT=nojsonv2 回到 v1 实现。这是过渡用的逃生阀,不是长期方案——官方明确它只为兼容问题兜底。
  • 求性能:直接把热路径切到 encoding/json/v2。收益主要在解码(官方说明"unmarshal performance is significantly faster"),编码大致持平。注意 v2 的严格校验会暴露原来被吞掉的脏数据(非法 UTF-8、重复键),对接收外部输入的路径要处理好错误分支。
  • 混合使用:Options 类型是别名,v1 包导出的 legacy 构造器可以传给 v2 函数(上面 FormatDurationAsNano 的实测就是证据)。别指望 encoding/json 本身能传选项。

注意:json.Marshaler / json.Unmarshaler 现在是 jsonv2.Marshaler / jsonv2.Unmarshaler 的别名,方法签名不变(MarshalJSON() ([]byte, error)),因此老代码的类型断言、接口实现继续有效。


3. omitempty 与 omitzero:两种"跳过"的语义

omitempty 的"empty"不是"零值",它是一套自己的判定;而 omitzero 才是"等于零值就跳过"。go1.27 实测(v1 与 v2 都对 omitzero 生效):

type Inner struct{ Value string }
type Row struct {
    A Inner      `json:"a,omitempty"`
    B Inner      `json:"b,omitzero"`
    C string     `json:"c,omitempty"`
    D int        `json:"d,omitzero"`
    E []string   `json:"e,omitempty"`
    F []string   `json:"f,omitzero"`
    G map[string]int `json:"g,omitzero"`
    H time.Time  `json:"h,omitzero"`
}
// 全零值,F 设为 []string{}:实测输出 {"a":{"Value":""},"f":[]}
// 全零值,F 为 nil:        实测输出 {"a":{"Value":""}}

结论拆开说:

  • omitempty 对 struct 类型无效(A 照样输出 {"Value":""}),这是最经典的误解——struct 永远不算 empty。omitzero 解决的就是它。
  • omitempty 对空 slice/map、空字符串、0、false、nil 指针都有效;omitzero 同样有效,但对非 nil 的空 slice([]string{})不跳过(F 输出了 []),因为 []string{} 不等于零值。
  • time.Time{} 用 omitempty 永远输出(struct),用 omitzero 会被跳过——注意 omitzero 对 time.Time 的判定用的是它的零值,不是"零时长"。

陷阱:想让 omitempty 生效于指针字段的指向值,靠的是指针本身 nil。var p *Inner = &Inner{} 依然会输出 {"Value":""},因为非 nil 指针不为 empty。这类"我明明写了 omitempty 为什么还是输出了"的问题,八成是指针/struct 组合。

新代码建议:语义上想表达"没有就不输出",一律 omitzero;只有需要区分 [] 与 null 这类历史兼容场景才留 omitempty。


4. json.RawMessage — 延迟解析

1.27 里 json.RawMessage = jsontext.Value,本质仍是 []byte,但类型定义在 encoding/json/jsontext。它的价值只有一个:解码时不解析,原样保存字节,是做多态载荷(envelope 模式)的核心手段。

type Message struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"` // 先不解析
}

type UserPayload struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

type OrderPayload struct {
    OrderID int     `json:"order_id"`
    Amount  float64 `json:"amount"`
}

func handleMessage(data []byte) error {
    var msg Message
    if err := json.Unmarshal(data, &msg); err != nil {
        return err
    }
    switch msg.Type {
    case "user":
        var u UserPayload
        return json.Unmarshal(msg.Payload, &u)
    case "order":
        var o OrderPayload
        return json.Unmarshal(msg.Payload, &o)
    default:
        return fmt.Errorf("unknown type %q", msg.Type)
    }
}

四点实践经验(都实测过):

  • 网关/透传场景:Payload json.RawMessage 能把不认识的字段原样转发,避免"结构化后再序列化丢字段"。这是 Go 写 API gateway 最常见的技巧。
  • Marshal 时会压缩:塞进 RawMessage 的 "{\n \"a\" : 1\n}" 输出成 {"payload":{"a":1}},空白被吃掉。若你要逐字节保真(例如做签名),RawMessage 不是存储原始字节的地方。
  • 字段缺失时是 nil:{"type":"t"} 解出来 Payload == nil,再编码时输出 "payload":null(不会报错)。判空要判 len(e.Payload) == 0。
  • 校验前置:它不豁免语法检查——上游给 {"payload":123abc} 时,Unmarshal 直接报 invalid character 'a' after object key:value pair,因为解析器必须自己定位值的边界。
  • 配合 jsontext:需要更细粒度控制(只读某个 token、手写 JSON 而不经过反射),直接用 encoding/json/jsontext 的 Encoder/Decoder,它工作在 token/value 层,不碰 Go 类型。

5. json.Number 与大整数精度

Unmarshal 到 any/map[string]any 时,所有 JSON 数字都变成 float64——超过 2^53 的整数必然丢精度。实测:

var v any
json.Unmarshal([]byte(`{"amount": 12345678901234567890}`), &v)
// map[amount:1.2345678901234567e+19]

两条出路:

(1)字段类型直接写 json.Number——注意这不需要 UseNumber(),UseNumber 只影响解码目标为 any 的情形:

type Tx struct {
    Amount json.Number `json:"amount"`
}

var tx Tx
err := json.Unmarshal(raw, &tx)        // err = nil
fmt.Printf("%q\n", tx.Amount.String()) // "12345678901234567890"

(2)解码到 any 时开 UseNumber():

dec := json.NewDecoder(r)
dec.UseNumber()
var v any
dec.Decode(&v)           // v.(map[string]any)["n"] 的类型是 json.Number,不是 float64

陷阱:json.Number.Int64() 在超范围时不是返回 0,而是返回 math.MaxInt64(或 MinInt64)并给出错误——如果只取第一个返回值,会静默拿到一个"看起来合法"的巨大数字。实测:

n := json.Number("12345678901234567890")
i, err := n.Int64()   // i = 9223372036854775807, err = strconv.ParseInt: ... value out of range

所以 Int64() 的 error 必须判。金额/ID 类字段如果要保真,一路用字符串传(或 json:"id,string" 让对端把数字写成字符串)。


6. 自定义 MarshalJSON / UnmarshalJSON

接口很薄,实现容易写出死循环和空指针问题。

6.1 自定义时间格式(避免递归)

type LocalTime struct {
    time.Time
}

const layout = "2006-01-02 15:04:05"

// 值接收者即可,但要注意零值与空串输入
func (t LocalTime) MarshalJSON() ([]byte, error) {
    return []byte(`"` + t.Format(layout) + `"`), nil
}

func (t *LocalTime) UnmarshalJSON(data []byte) error {
    s := strings.Trim(string(data), `"`)
    if s == "" || s == "null" {
        t.Time = time.Time{}
        return nil
    }
    parsed, err := time.ParseInLocation(layout, s, time.Local)
    if err != nil {
        return err
    }
    t.Time = parsed
    return nil
}

要点:

  • 返回的字节必须是合法 JSON(含引号)。少写引号是最常见的 bug,症状是 invalid character 报错指向调用方而不是你。
  • 不要给同一个类型既嵌入又调用 json.Marshal(t)——那会再次命中你的 MarshalJSON,栈溢出。惯例是定义新类型绕过:type alias T 然后 json.Marshal(alias(t))。
  • UnmarshalJSON 的接收者必须是指针,否则无法写回。

6.2 枚举与 nil 指针

type Status int

const (
    StatusUnknown Status = iota
    StatusActive
    StatusClosed
)

var statusNames = map[Status]string{StatusActive: "active", StatusClosed: "closed"}

func (s Status) MarshalJSON() ([]byte, error) {
    if n, ok := statusNames[s]; ok {
        return json.Marshal(n)
    }
    return json.Marshal(int(s)) // 未知值退化成数字,比报错友好
}

注意:nil 指针永远不会走到你的 MarshalJSON。实测(go1.27,方法里已写 if p == nil 分支):

json.Marshal([]*Ptr{nil})   // [null]        —— 方法未被调用
json.Marshal(struct{ P *Ptr }{}) // {"p":null} —— 同样未被调用

想让 null 输出成别的字符串,只能在外层类型上做文章(字段用值类型 + omitzero,或用一个包装类型的 MarshalJSON 生成整个对象)。反方向不同:UnmarshalJSON 是指针接收者,解码到 nil 指针字段时包会先分配再调用(实测 recv nil = false);但 JSON 值恰好是 null 时方法同样不被调用,指针保持 nil。

另一个高频问题是 MarshalJSON 返回值不合法(少引号、多空格)。错误会被包装成类型名可定位的形式,实测:

json: error calling MarshalJSON for type *main.PtrOnly: invalid character '"' after top-level value

看到 error calling MarshalJSON for type X 就说明问题在 X 自己的实现里,不要在被编码的结构体里瞎找。

6.3 只想要 RawMessage 的效果

如果目的只是"这个字段的 JSON 由外部决定",json.RawMessage 比手写 MarshalJSON 更简单,且天然可空(nil 时输出 null)。


7. 流式编解码

7.1 Encoder:NDJSON 与大响应

enc := json.NewEncoder(w)
enc.SetIndent("", "  ")   // 需要缩进时才开
if err := enc.Encode(item); err != nil {
    return err
}

陷阱:Encoder.Encode 每次写入都会在末尾追加一个换行符(实测输出 {"k":1}\n)。这不是 bug,而是刻意设计:JSON stream(RFC 7464 的 text sequence 风格)与 NDJSON 都依赖这个分隔符。副作用是——

  • 把 Encode 结果当"完整 JSON 文档"再 json.Unmarshal 没问题(解析器容忍尾部空白);
  • 但做内容哈希、签名、逐字节 diff 时,这多出的 \n 会让你和别的语言实现对不上;
  • 写到 HTTP response 时,Go 会走 chunked 编码,Content-Length 拿不到,需要精确长度的场景先 Marshal 再写 body。

Encode 单个值是原子的:实测把含 chan 字段的结构体编码失败时,writer 里没有半截字节(buf="",err=json: unsupported type: chan int),仍然是上一个完整值。所以连续 Encode 写 NDJSON 时,出错那一条不会污染文件,但也别指望它能把超大值边生成边吐出去——需要增量写字节流,v2 提供了 MarshalWrite/MarshalEncode。

7.2 Decoder:大文件与逐 token

// 逐条解码 JSON 数组(不整体读入内存)
dec := json.NewDecoder(f)
dec.Token()                 // 吃掉开头的 '['
for dec.More() {             // 判断数组里是否还有元素
    var item Record
    if err := dec.Decode(&item); err != nil {
        return fmt.Errorf("record %d: %w", n, err)
    }
    n++
}

实用细节:

  • Decoder 会缓冲多于一个值的数据。实测:解码 {"a":1} trailing 后,dec.Buffered() 里仍留着未消费的 trailin…。用它读 http.Response.Body 里的多个 JSON 值时,如果提前 Close body,留在 Decoder 缓冲里的字节就丢了;dec.Buffered() 可以把剩余字节交还给后续逻辑(同一个连接上换协议继续读写时尤其重要)。

  • dec.UseNumber() 只作用于解码到 any 的目标(见 §5)。

  • dec.DisallowUnknownFields() 用来把"字段拼错"从静默忽略变成显式错误,配置文件解析强烈建议开:

    dec.DisallowUnknownFields()
    err := dec.Decode(&cfg)   // json: unknown field "zzz"

    v2 等价写法:jsonv2.Unmarshal(b, &cfg, jsonv2.RejectUnknownMembers(true))。

  • dec.InputOffset() 返回出错时的字节偏移,处理上游大 JSON 时定位问题字段很有用(v2 的错误消息里本来就带 after offset N)。

7.3 校验与格式化

if !json.Valid(data) { /* 非法 JSON */ }   // 实测:json.Valid([]byte("{}{}")) == false
var buf bytes.Buffer
json.HTMLEscape(&buf, data)
json.Compact(&buf, data)
json.Indent(&buf, data, "", "  ")

json.Valid 只做语法检查,不做语义检查,适合"进来先挡一道"。注意它认定 {}{} 非法(一个文档只允许一个顶层值),而 Decoder 的流式语义允许连续多个值——两者判定标准不同,别把 Valid 用在 NDJSON 上。


8. 陷阱汇总表

# 现象 原因与对策
1 Marshal 少字段 字段名首字母小写(非导出)。加导出字段或写 MarshalJSON
2 omitempty 没生效 对 struct 永远无效;改 omitzero(§3)
3 大整数 ID 变成 1.2345678901234567e+19 any 目标走 float64;用 json.Number 字段或 UseNumber()
4 Int64() 返回天文数字且不报错? error 被忽略了;溢出时它返回 MaxInt64 + error(实测)
5 富文本被转成 < v1 API 默认 HTML 转义。用 Encoder.SetEscapeHTML(false),或切 v2(默认不转义)
6 上游字段名大小写不一致,接不到值 v1 大小写不敏感、v2 严格。要容错写 json:"name,case:ignore"(实测 v1/v2 都生效)
7 time.Duration 编码到对端变成纳秒大整数 v1 行为;v2 直接报错。字段用秒(float64/int)或字符串(“1s”)自己转
8 map 输出顺序变了导致 diff/签名不稳定 v2 不排序;jsonv2.Deterministic(true),或换 slice
9 循环引用 panic? 不是 panic,是错误:json: unsupported value: encountered a cycle via map[string]interface {};NaN/Inf 同样报 unsupported value: NaN
10 Unmarshal 到已有值的结构体,旧数据没被清空 设计如此:逐字段合并,JSON 里没出现的键保留原值。复用变量前先归零
11 map 的 key 类型不被支持 只有 string、整数、以及实现了 MarshalText/MarshalJSON 的类型可作 key(v1 不调 key 的 MarshalJSON,实测 v2 才支持);map[[2]int]int 直接报错
12 解码后 Rows/Items 是 null 而非 [] 上游就给了 null,或 v1 语义下的 nil slice。前端判空要同时看 null 与 []
13 json.Unmarshal(body, &x) 忽略了 Decode 的流语义 Unmarshal 要求整个输入恰好是一个值;带尾随数据({}{})会报错
14 错误类型判断 用 *json.SyntaxError/*json.UnmarshalTypeError 配合 errors.As;InvalidUTF8Error、UnmarshalFieldError 已标注 Deprecated 且不再产生
15 畸形键名标签 json:"'-'" / json:"-," v1 一个静默忽略、一个能用;v2 两个都报错。别再依赖这类写法(§1)

9. 实战建议

API 服务的默认选择就是 encoding/json,不要一上来就引第三方。 Go 1.27 之后它的解码性能明显改善,反射开销不再是换库的首要理由。真到了 profile 显示 JSON 是热点时,再按顺序考虑:切 encoding/json/v2 → 减少一次性的 map[string]any 解析(改结构体)→ 热路径用 MarshalWrite 直接写 io.Writer 省一次 []byte 分配 → 最后才考虑 codegen 方案。

给外部系统的载荷,用结构体而不是 map[string]any。 结构体的字段顺序确定、编译期能查拼写、case:ignore 之类语义清晰;map 除了顺序(v2 不排序)还容易把 nil 与空 map 的区分弄丢。

解码入口做严格化,编码出口做宽松化。 配置文件、内部 RPC 一律 DisallowUnknownFields(拼错字段静默失效的成本,远高于升级时多改一行);对外 HTTP 响应则不要开这类严格校验,避免版本演进时把老客户端打死。

迁移到 1.27 时,先跑一遍解码路径的测试。 上面差异表里的非法 UTF-8、重复键、time.Duration、nil slice 四项,绝大多数只在"输入不干净"或"类型特殊"时炸,而这三类正是单元测试覆盖最薄的地方。真出问题时 GOEXPERIMENT=nojsonv2 给你退回的余地,但请把它当临时措施登记到 TODO 里,别变成永久技术债。

日志字段序列化不要用 json.Marshal 拼字符串。 结构化日志交给 log/slog(见 ../01-基础核心包/errors-and-log.md)处理;它的 Handler 内部有自己的编码策略,且能避免把错误对象、time.Duration 这类特殊类型序列化失败拖垮整条日志。

相关阅读:本目录的 encoding-gob.md(Go 内部 RPC/持久化的二进制格式)、hash-mac-aead.md(hex/base64 与哈希选型)、text-template.md(HTML 输出场景下的转义边界);HTTP 层用法见 ../03-网络编程/net-http.md;标准库新增 uuid 包见 ../01-基础核心包/uuid.md。