GoSuda

Analysis of Behavioral Changes in Go 1.25 encoding/json/v2 and the v1 Compatibility Layer

By lemonmint views ...

The encoding/json package v2 in Go is a new implementation designed to improve upon several shortcomings of the existing v1 (inconsistency, surprising behavior, performance issues). It is an experimental feature that is enabled through the goexperiment.jsonv2 build tag.

Most importantly, when v2 is enabled, v1 operates as a compatibility layer that emulates v1's behavior on top of the v2 implementation. This is achieved through the DefaultOptionsV1() function in the v2_v1.go file. In other words, v2 provides options that can fully reproduce v1's behavior, while simultaneously introducing stricter and more predictable new default behaviors.

The main goals of v2 are as follows.

  1. Improved correctness and predictability: By default, stricter rules (e.g., case-sensitive matching, prohibition of duplicate keys) are applied to reduce unexpected behavior.
  2. Performance improvements: The parsing and encoding engines have been redesigned to increase efficiency.
  3. Expanded flexibility and control: A detailed options system is introduced so that developers can finely control how JSON is processed.

Major Semantic and Behavioral Differences

Based on the v2_test.go file, the behavioral differences between v1 and v2 are summarized by item.

1. Field Name Matching (Case Sensitivity)

  • v1 behavior: When unmarshaling JSON object members into Go struct fields, matching is performed case-insensitively. Both "FirstName" and "firstname" are mapped to the FirstName field.
  • v2 behavior: By default, matching is case-sensitive, and only fields that match exactly are mapped.
  • Reason for change: Case-insensitive matching can cause unexpected behavior and incurs a performance penalty when handling non-matching fields. v2 adopts clearer and more predictable behavior as the default.
  • Related options: In v2, case-insensitivity can be explicitly enabled per field using the json:"...,case:ignore" tag option, or applied globally with the json.MatchCaseInsensitiveNames(true) option.

2. Change in Meaning of the omitempty Tag Option

  • v1 behavior: Fields are omitted based on the "empty state" of the Go value. Here, "empty state" means false, 0, nil pointers/interfaces, and arrays/slices/maps/strings with a length of 0.
  • v2 behavior: Fields are omitted based on the "empty state" of the encoded JSON value. That is, they are omitted when encoded as null, "", {}, or [].
  • Reason for change: The v1 definition is tied to Go's type system. v2 provides more consistent behavior by basing it on the JSON type system. For example, in v1 a bool field with the value false is omitted, whereas in v2 false is a non-empty JSON value and is not omitted. In v2, the omitzero option has been added so that it can replace the v1 behavior where omitempty applied to 0 or false.
  • Related options: To obtain the same behavior as v1 in v2, use the json.OmitEmptyWithLegacyDefinition(true) option.

3. Change in Behavior of the string Tag Option

  • v1 behavior: Applies to fields of numeric, boolean, and string types. The value is re-encoded inside a JSON string (e.g., int(42) -> "42"). It is not applied recursively to values inside composite types (slices, maps, etc.).
  • v2 behavior: Applies only to numeric types, and it is applied recursively. That is, numbers inside slices such as []int are also all encoded as JSON strings.
  • Reason for change: The primary purpose of the string option is to represent numbers as strings to prevent precision loss in 64-bit integers. The v1 behavior was limited and lacked consistency. v2 focuses on this core purpose and extends the behavior recursively to make it more useful.
  • Related options: The v1 behavior can be emulated with the json.StringifyWithLegacySemantics(true) option.

4. Marshaling of nil Slices and Maps

  • v1 behavior: nil slices and nil maps are marshaled as null.
  • v2 behavior: By default, nil slices are marshaled as [] (an empty array) and nil maps are marshaled as {} (an empty object).
  • Reason for change: nil is an implementation detail of the Go language, and exposing it in a language-independent JSON format is undesirable. [] and {}, which represent empty collections, are more universal representations.
  • Related options: In v2, the json.FormatNilSliceAsNull(true) or json.FormatNilMapAsNull(true) options can be used to marshal them as null as in v1.

5. Array Unmarshaling

  • v1 behavior: When unmarshaling into a Go array ([N]T), no error is raised even if the length of the JSON array differs from the length of the Go array. If the JSON array is shorter, the remaining elements are filled with zero values; if it is longer, the excess is discarded.
  • v2 behavior: The length of the JSON array must exactly match the length of the Go array. Otherwise, an error is raised.
  • Reason for change: In Go, the length of a fixed-size array often carries important meaning. The v1 behavior can lead to silent data loss. v2 enhances correctness through stricter rules.
  • Related options: The v1 behavior can be emulated with the json.UnmarshalArrayFromAnyLength(true) option.

6. Handling of time.Duration

  • v1 behavior: time.Duration is internally treated as an int64 and is encoded as a JSON number in nanoseconds.
  • v2 behavior: It is encoded as a JSON string in a format such as "1h2m3s", using the time.Duration.String() method.
  • Reason for change: Numeric nanoseconds are less readable, and the standard string representation of time.Duration is more useful.
  • Related options: The v1 behavior can be used through the json:",format:nano" tag option or the json.FormatTimeWithLegacySemantics(true) option.

7. Handling of Invalid UTF-8

  • v1 behavior: During marshaling/unmarshaling, if a string contains invalid UTF-8 bytes, they are silently replaced with the Unicode replacement character (\uFFFD).
  • v2 behavior: By default, an error is returned when invalid UTF-8 is encountered.
  • Reason for change: This prevents silent data corruption and aligns with the stricter JSON standard (RFC 7493).
  • Related options: The v1 behavior can be emulated with the jsontext.AllowInvalidUTF8(true) option.

8. Handling of Duplicate Object Member Names

  • v1 behavior: Allows members with the same name to appear multiple times within a JSON object. The last occurring value overwrites the others.
  • v2 behavior: By default, an error is returned if duplicate member names are present.
  • Reason for change: RFC 8259 does not define the behavior for duplicate names, so implementations may behave differently. This can be a source of security vulnerabilities. v2 explicitly rejects them to improve correctness and security.
  • Related options: The v1 behavior can be emulated with the jsontext.AllowDuplicateNames(true) option.

Implementation and Architectural Differences

  • v1: Relies heavily on the decodeState in decode.go and the manually written state machine in scanner.go. This is a monolithic structure in which parsing logic and semantic analysis are tightly coupled.
  • v2: The architecture is more modular.
    • encoding/json/jsontext: Provides a low-level, high-performance JSON tokenizer (Decoder) and encoder (Encoder). This package focuses solely on the syntactic aspects of JSON.
    • encoding/json/v2: Builds on jsontext to handle the semantic conversion between Go types and JSON values.
    • Through this separation, syntactic analysis and semantic analysis are decoupled, improving both code clarity and performance.

New APIs and Features in v2

v2 provides highly flexible control capabilities through the json.Options system.

  • json.Options: A set of options that change marshaling/unmarshaling behavior.
  • json.JoinOptions(...): Merges multiple options into one.
  • WithMarshalers / WithUnmarshalers: A powerful feature that allows injecting serialization/deserialization logic for specific types without implementing the Marshaler/Unmarshaler interfaces. This is particularly useful when handling types from external packages.
  • New options: Various behavioral controls that were not possible in v1 are now available, such as RejectUnknownMembers, Deterministic(false), and FormatNilSliceAsNull.

Conclusion

encoding/json v2 is a modern implementation that substantially improves correctness, performance, and flexibility based on the experience gained from v1. Although the default behavior has become stricter, the sophisticated Options system fully supports all of v1's behaviors, so existing code compatibility is maintained while the advantages of v2 can be adopted incrementally.

  • For new projects, it is recommended to use v2 by default.
  • Existing projects may continue to use jsonv1 as is, or adopt a strategy of migrating to jsonv2 and gradually introducing v2's stricter behavior through DefaultOptionsV1().