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

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.
- Improved correctness and predictability: By default, stricter rules (e.g., case-sensitive matching, prohibition of duplicate keys) are applied to reduce unexpected behavior.
- Performance improvements: The parsing and encoding engines have been redesigned to increase efficiency.
- 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 theFirstNamefield. - 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 thejson.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
boolfield with the valuefalseis omitted, whereas in v2falseis a non-empty JSON value and is not omitted. In v2, theomitzerooption has been added so that it can replace the v1 behavior whereomitemptyapplied to0orfalse. - 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
[]intare also all encoded as JSON strings. - Reason for change: The primary purpose of the
stringoption 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:
nilslices andnilmaps are marshaled asnull. - v2 behavior: By default,
nilslices are marshaled as[](an empty array) andnilmaps are marshaled as{}(an empty object). - Reason for change:
nilis 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)orjson.FormatNilMapAsNull(true)options can be used to marshal them asnullas 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.Durationis internally treated as anint64and is encoded as a JSON number in nanoseconds. - v2 behavior: It is encoded as a JSON string in a format such as
"1h2m3s", using thetime.Duration.String()method. - Reason for change: Numeric nanoseconds are less readable, and the standard string representation of
time.Durationis more useful. - Related options: The v1 behavior can be used through the
json:",format:nano"tag option or thejson.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
decodeStateindecode.goand the manually written state machine inscanner.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 onjsontextto 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 theMarshaler/Unmarshalerinterfaces. 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), andFormatNilSliceAsNull.
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
jsonv1as is, or adopt a strategy of migrating tojsonv2and gradually introducing v2's stricter behavior throughDefaultOptionsV1().