JSON is one of the most common formats used to exchange data between services. Whether you are building an HTTP API, consuming an external service, processing configuration files, or communicating between microservices, chances are that JSON is part of your daily work.
Go has supported JSON in its standard library for a very long time through encoding/json. Its API is familiar, relatively simple, and flexible enough for a wide range of applications. However, years of real-world use also exposed limitations in its behavior, API design, and performance model.
Go 1.27 introduces a major step forward: encoding/json/v2, together with a lower-level package named encoding/json/jsontext.
This is not simply a collection of new helper functions. It is a redesign of how Go processes JSON, with stricter defaults, a cleaner separation of responsibilities, more flexible customization, and APIs that support genuinely streaming JSON processing.
In this article, we will look at why the original package needed a successor, what changed in the new design, how the APIs work, and what developers should consider before adopting them in production.
1. The Original encoding/json: Simple, Familiar, and Difficult to Change
The original package made JSON serialization straightforward. Define a Go type, add struct tags, and call Marshal or Unmarshal.
1package main 2 3import ( 4 encoding/json 5 fmt 6) 7 8type User struct { 9 ID int64 `json:id` 10 Name string `json:name` 11} 12 13func main() { 14 user := User{ 15 ID: 42, 16 Name: Alice, 17 } 18 19 data, err := json.Marshal(user) 20 if err != nil { 21 panic(err) 22 } 23 24 fmt.Println(string(data)) 25}
The output is:
1{id:42,name:Alice}
Decoding is just as familiar:
1var user User 2 3if err := json.Unmarshal(data, &user); err != nil { 4 panic(err) 5}
This design has served Go developers well. The problem is not that the package was poorly designed for its time. The problem is that its behavior became part of the Go 1 compatibility promise.
Once applications depend on a behavior, changing that behavior can break real systems, even when the old behavior looks surprising or undesirable in hindsight.
Over time, the Go team identified several issues that could not be fixed cleanly without changing existing semantics.
1.1 Invalid UTF-8 Was Silently Replaced
JSON text must use UTF-8. However, the original package accepted invalid UTF-8 bytes inside JSON strings and replaced them with the Unicode replacement character.
That means the decoded string could differ from the bytes supplied by the sender, without the caller receiving an error.
For example, imagine a service receives a malformed JSON string from another system. Instead of rejecting the input, the old decoder may silently modify the string. That can make debugging difficult and can create inconsistencies when different services process the same payload.
The new API rejects invalid UTF-8 by default.
This is a better default for systems that need predictable data handling: malformed input should be visible rather than silently rewritten.
1.2 Duplicate Object Names Were Accepted
Consider this JSON document:
1{ 2 role: user, 3 role: admin 4}
The object contains the same member name twice.
The JSON standard does not define one universal interpretation for duplicate names. Different parsers may keep the first value, keep the last value, or handle the input in another way.
The original Go package accepted duplicate names. When decoding into a Go value, later values could replace or merge with earlier values depending on the destination type.
This becomes particularly important in systems where multiple components inspect the same request. If an API gateway, validation layer, and backend service interpret duplicate names differently, they may disagree about the meaning of a request.
That kind of parser differential can become a security concern.
encoding/json/v2 rejects duplicate object names by default. If an application has a specific reason to accept them, it can explicitly opt in.
1.3 Struct Field Matching Was Case-Insensitive
Given this type:
1type User struct { 2 Name string `json:name` 3}
The original package could match all of the following JSON member names to the same Go field:
1{name: Alice}
1{Name: Alice}
1{NAME: Alice}
This permissive behavior can be convenient, but it is not always desirable. JSON member names are generally treated as case-sensitive by other implementations. A Go service that accepts multiple spellings may behave differently from another service written in a different language.
The v2 API uses case-sensitive matching by default. It also provides an option for applications that need the old case-insensitive behavior.
1.4 The API Made Some Common Tasks Awkward
The original package offers Marshal, Unmarshal, Encoder, and Decoder. However, some configuration options are available only through the encoder or decoder types.
For example, Decoder.DisallowUnknownFields cannot simply be passed to json.Unmarshal. Options also do not naturally flow through custom MarshalJSON and UnmarshalJSON methods.
This creates an inconsistent experience. A caller may configure a decoder, but a custom unmarshaler deeper in the call stack does not automatically receive that configuration.
The v2 API addresses this by making options explicit arguments to the main functions and by allowing custom implementations to work with the new encoder and decoder.
1.5 The Old Custom Interfaces Limited Performance
The original custom interfaces look like this:
1type Marshaler interface { 2 MarshalJSON() ([]byte, error) 3} 4 5type Unmarshaler interface { 6 UnmarshalJSON([]byte) error 7}
These methods are easy to understand, but their signatures impose costs.
A custom marshaler must construct a complete JSON representation and return it as a []byte. The JSON package then has to validate that output and may need to reformat it.
A custom unmarshaler receives a complete JSON value as a byte slice. Before calling the method, the package has to identify the end of that value. The custom method may then parse the same data again.
In recursive data structures, repeatedly parsing nested JSON can become particularly expensive.
1.6 The Old Decoder Was Not Fully Streaming
The original Encoder and Decoder work with io.Writer and io.Reader, which can make them look like streaming APIs.
However, the old implementation buffers a complete JSON value in memory. Its token-reading API can also involve significant allocations, and there is no matching low-level API for writing a stream of tokens.
This distinction matters. Reading from an io.Reader does not automatically mean that a parser processes a JSON document incrementally with bounded memory.
2. Why Not Just Fix the Existing Package?
Go places a strong emphasis on compatibility. The Go 1 compatibility promise means that existing programs should continue to work across future Go releases, subject to the documented compatibility rules.
Changing the behavior of encoding/json directly would risk breaking applications that rely on its existing semantics.
For example, an application might depend on nil slices being encoded as null, or on case-insensitive field matching. Even if a stricter behavior is more consistent with the JSON ecosystem, changing the default could alter API responses or input handling in production.
The Go team therefore introduced a new package path:
1import encoding/json/v2
The original package remains available:
1import encoding/json
This gives developers a clear migration boundary. The new package can adopt better defaults without silently changing the behavior of existing applications.
An important detail is that the two packages are designed to interoperate. The v1 package is implemented using the new v2 implementation with compatibility options. Existing code does not need to migrate just because the new implementation exists.
3. The New Architecture: Separating Syntax from Semantics
One of the most important design changes is the separation of JSON syntax processing from the mapping between JSON values and Go values.
These are related, but they are not the same responsibility.
Syntax
Syntax concerns the JSON format itself:
- Reading JSON tokens.
- Writing JSON tokens.
- Recognizing strings, numbers, arrays, and objects.
- Validating JSON grammar.
- Processing complete JSON values.
- Maintaining valid object and array structure while encoding or decoding.
Semantics
Semantics concerns how JSON values correspond to Go values:
- Mapping JSON objects to structs or maps.
- Mapping JSON arrays to slices or arrays.
- Converting JSON numbers to Go numeric types.
- Applying struct tags.
- Calling custom marshalers and unmarshalers.
- Deciding how nil values and zero values should be represented.
The new design assigns these responsibilities to two packages.
1 Go values 2 | 3 v 4 encoding/json/v2 5 Semantic layer 6 ------------------------- 7 Structs, maps, slices 8 Reflection and struct tags 9 Marshal and Unmarshal 10 Custom type representations 11 ------------------------- 12 | 13 v 14 encoding/json/jsontext 15 Syntax layer 16 ------------------------- 17 JSON values and tokens 18 JSON grammar validation 19 Streaming Encoder / Decoder 20 ------------------------- 21 | 22 v 23 JSON text
The encoding/json/v2 package handles the relationship between Go values and JSON values.
The encoding/json/jsontext package handles JSON text itself. It does not need to know whether a JSON object represents a Go struct, a map, or a custom application type.
This separation makes the lower-level package useful on its own. A developer can inspect, transform, or produce JSON without necessarily converting it into Go structs.
It also gives the higher-level package a better foundation for streaming and customization.
4. Introducing encoding/json/v2
For common use cases, the v2 API will feel familiar.
1package main 2 3import ( 4 fmt 5 6 json encoding/json/v2 7) 8 9type User struct { 10 ID int64 `json:id` 11 Name string `json:name` 12} 13 14func main() { 15 user := User{ 16 ID: 42, 17 Name: Alice, 18 } 19 20 data, err := json.Marshal(user) 21 if err != nil { 22 panic(err) 23 } 24 25 fmt.Println(string(data)) 26 27 var decoded User 28 if err := json.Unmarshal(data, &decoded); err != nil { 29 panic(err) 30 } 31 32 fmt.Printf(%+v\n, decoded) 33}
The main difference is the import path. Existing code using basic Marshal and Unmarshal calls often requires only a small source change.
However, the new package provides more than those two functions.
4.1 The Main API Functions
The v2 package provides several ways to work with JSON:
| Function | Purpose |
|---|---|
Marshal | Converts a Go value into a JSON byte slice. |
MarshalWrite | Writes a Go value as JSON directly to an io.Writer. |
MarshalEncode | Marshals a Go value through a jsontext.Encoder. |
Unmarshal | Decodes a JSON byte slice into a Go value. |
UnmarshalRead | Reads JSON from an io.Reader into a Go value. |
UnmarshalDecode | Decodes a JSON value through a jsontext.Decoder. |
All of these functions accept optional Options values.
This makes configuration more consistent. Instead of choosing between a convenience function and a separately configured encoder or decoder, callers can pass options directly.
For example:
1data, err := json.Marshal( 2 user, 3 json.Deterministic(true), 4) 5if err != nil { 6 panic(err) 7}
The Deterministic option requests stable output for the same input value. For native Go maps, this includes sorting keys.
Deterministic output can be useful in tests, reproducible output, hashing workflows, and systems where stable serialized representations are valuable. It should not be confused with a complete canonical JSON implementation or a guarantee that output is identical across different builds and toolchain versions.
5. Important Behavioral Changes
The most visible differences between v1 and v2 are the defaults used when encoding and decoding values.
These changes are intentional. They aim to make Go's behavior more predictable and interoperable with other JSON implementations.
5.1 Nil Slices and Maps
In v1, a nil slice is encoded as null.
1var names []string 2 3data, err := jsonv1.Marshal(names) 4if err != nil { 5 panic(err) 6} 7 8fmt.Println(string(data))
Output:
1null
In v2, a nil slice is encoded as an empty JSON array:
1var names []string 2 3data, err := jsonv2.Marshal(names) 4if err != nil { 5 panic(err) 6} 7 8fmt.Println(string(data))
Output:
1[]
The same principle applies to maps.
| Go value | v1 output | v2 output |
|---|---|---|
var s []string | null | [] |
[]string{} | [] | [] |
var m map[string]int | null | {} |
map[string]int{} | {} | {} |
This can be a useful improvement for APIs. A client that expects a collection can consistently receive an array or object rather than having to handle both null and an empty collection.
However, it is still a behavior change. If existing clients distinguish between null and an empty collection, changing the serializer can break their assumptions.
5.2 omitempty and omitzero
The difference between omitempty and omitzero is especially important during migration.
In v1, omitempty uses Go's notion of an empty value. That includes values such as:
-
0 -
false -
nil pointers
-
empty slices and maps
In v2, omitempty is based on the resulting JSON representation. It omits a field if that representation is one of these JSON values:
-
null -
[] -
{}
Consequently, numeric zero and boolean false are not omitted by v2's omitempty.
Consider:
1type Settings struct { 2 Retries int `json:retries,omitempty` 3 Enabled bool `json:enabled,omitempty` 4}
With v1, a zero-valued Settings can produce:
1{}
With v2, it produces:
1{retries:0,enabled:false}
If the intention is to omit Go zero values, use omitzero:
1type Settings struct { 2 Retries int `json:retries,omitzero` 3 Enabled bool `json:enabled,omitzero` 4}
This distinction is useful when designing APIs where 0 and false are meaningful values rather than indicators that a field is absent.
A practical rule:
- Use
omitemptywhen you want to omit values that encode as empty JSON values. - Use
omitzerowhen you want to omit Go zero values.
For some fields, using both options can be appropriate.
5.3 Case-Sensitive Field Matching
In v1, JSON member names are matched to struct fields using a case-insensitive fallback.
In v2, matching is case-sensitive by default.
1type User struct { 2 Name string `json:name` 3}
This input matches:
1{name: Alice}
This input does not match the field by default:
1{NAME: Alice}
The unmatched member is ignored unless unknown members are configured to be rejected.
If an application needs the old behavior, it can explicitly request case-insensitive matching:
1err := jsonv2.Unmarshal( 2 data, 3 &user, 4 jsonv2.MatchCaseInsensitiveNames(true), 5)
Strict matching makes the JSON contract more explicit and reduces surprising differences between Go services and systems written in other languages.
5.4 Unknown Object Members
Both v1 and v2 ignore unknown object members by default when decoding into a struct.
For example:
1{ 2 name: Alice, 3 department: Engineering 4}
If the Go struct contains only a Name field, department is ignored by default.
When strict input validation is required, v2 provides an option to reject unknown members:
1err := jsonv2.Unmarshal( 2 data, 3 &user, 4 jsonv2.RejectUnknownMembers(true), 5)
This is useful for validating configuration files, internal service contracts, and API requests where unexpected fields should not be silently accepted.
It is not always the right choice for public APIs. Some APIs intentionally allow clients and servers to evolve independently, so ignoring unknown fields can be a useful compatibility strategy.
5.5 Invalid UTF-8 and Duplicate Names
The new defaults reject invalid UTF-8 and duplicate object member names.
These defaults improve interoperability and reduce ambiguity.
The important point is that stricter parsing is not merely a style preference. In a distributed system, different components may use different JSON libraries. If they disagree about how malformed or ambiguous input should be interpreted, the system can end up with inconsistent views of the same request.
Rejecting ambiguous input early is often safer than trying to guess what the sender intended.
6. encoding/json/jsontext: Working with JSON at a Lower Level
The second new package is encoding/json/jsontext.
It is responsible for processing JSON syntax independently of Go application types.
Its main abstractions are:
Value, a complete JSON value represented as a byte slice type.Token, an opaque representation of an individual JSON token.Encoder, which writes JSON values and tokens.Decoder, which reads JSON values and tokens.
This package is useful when an application needs to inspect or transform JSON without first mapping the entire document into a Go struct.
6.1 Reading a Token
1package main 2 3import ( 4 fmt 5 strings 6 7 encoding/json/jsontext 8) 9 10func main() { 11 input := `{name:Alice,active:true}` 12 13 decoder := jsontext.NewDecoder(strings.NewReader(input)) 14 15 token, err := decoder.ReadToken() 16 if err != nil { 17 panic(err) 18 } 19 20 fmt.Println(token) 21}
The decoder reads the next JSON token from the input stream.
A token-based API is useful for custom parsers, JSON inspection tools, and transformations that need to operate on the structure of a document rather than on a predefined Go type.
The Token type is intentionally opaque. Callers use its constructors and accessor methods rather than depending on an exposed internal representation.
6.2 Writing a JSON Value
The package also provides a low-level encoder:
1package main 2 3import ( 4 os 5 6 encoding/json/jsontext 7) 8 9func main() { 10 encoder := jsontext.NewEncoder(os.Stdout) 11 12 value := jsontext.Value(`{name:Alice,active:true}`) 13 14 if err := encoder.WriteValue(value); err != nil { 15 panic(err) 16 } 17}
WriteValue writes a complete JSON value. The encoder maintains the state needed to ensure that the sequence of values and tokens it writes forms valid JSON text.
For more advanced use cases, code can write individual tokens and build JSON incrementally.
6.3 Why This Is Different from the Old Decoder
The original json.Decoder accepts an io.Reader, but that alone does not make it a fully streaming token processor.
The new jsontext.Decoder is designed around incremental processing of JSON tokens and values. It can read the next piece of JSON without requiring the application to first materialize the entire document as a Go value.
This is valuable when processing large documents, implementing custom transformations, or building tools that need to inspect JSON structure with more control over memory use.
Streaming does not automatically guarantee lower memory usage in every application. The consumer can still retain data, build large structures, or introduce its own buffers. The benefit is that the API no longer requires every custom operation to start with a complete JSON value in memory.
7. Better Custom Serialization Interfaces
Custom serialization is one of the areas where the new architecture makes a particularly meaningful difference.
The original interfaces remain supported:
1type Marshaler interface { 2 MarshalJSON() ([]byte, error) 3} 4 5type Unmarshaler interface { 6 UnmarshalJSON([]byte) error 7}
However, v2 introduces streaming-oriented alternatives:
1type MarshalerTo interface { 2 MarshalJSONTo(*jsontext.Encoder) error 3} 4 5type UnmarshalerFrom interface { 6 UnmarshalJSONFrom(*jsontext.Decoder) error 7}
The new methods let custom types work directly with the JSON encoder and decoder.
7.1 A Custom Marshaler
Suppose an application has a custom identifier type that must be represented as a JSON string.
1package main 2 3import ( 4 encoding/json/jsontext 5) 6 7type UserID int64 8 9func (id UserID) MarshalJSONTo( 10 enc *jsontext.Encoder, 11) error { 12 value := jsontext.Value(`user-42`) 13 return enc.WriteValue(value) 14}
This example uses a fixed value to demonstrate the API. In a real implementation, the JSON string should be constructed from the actual identifier, with proper JSON string escaping. Avoid building JSON by concatenating untrusted strings.
The important difference is that the method writes through the encoder instead of returning a newly allocated []byte.
7.2 A Custom Unmarshaler
The same idea applies to decoding.
1type CustomValue struct { 2 Name string 3} 4 5func (v *CustomValue) UnmarshalJSONFrom( 6 dec *jsontext.Decoder, 7) error { 8 value, err := dec.ReadValue() 9 if err != nil { 10 return err 11 } 12 13 // Parse or interpret value according to the type's rules. 14 _ = value 15 16 return nil 17}
This is a deliberately small sketch. A production implementation would validate the expected JSON shape and populate the receiver.
The key advantage is that the custom method receives a decoder rather than a pre-buffered byte slice. This makes incremental processing possible and gives the implementation access to the decoder's options.
7.3 Options Can Flow Through Custom Implementations
In the old API, options configured by the caller could be lost when decoding entered a custom UnmarshalJSON implementation.
The new encoder and decoder expose their options, allowing custom implementations to respect the same configuration as the outer operation.
This is important for reusable libraries. A custom type should not have to ignore the caller's configuration simply because it implements its own serialization behavior.
8. More Flexible Customization with Options
The v2 API accepts variadic Options values across its main operations.
For example:
1data, err := json.Marshal( 2 user, 3 json.Deterministic(true), 4)
Options can also be combined. When multiple options configure the same behavior, later options take precedence.
This gives callers more control without requiring a custom encoder type for every operation.
A few useful options include:
| Option | Purpose |
|---|---|
Deterministic(true) | Requests deterministic output for the same input. |
RejectUnknownMembers(true) | Rejects object members that do not match struct fields. |
MatchCaseInsensitiveNames(true) | Enables case-insensitive field matching. |
FormatNilSliceAsNull(true) | Restores the v1 nil-slice output behavior. |
FormatNilMapAsNull(true) | Restores the v1 nil-map output behavior. |
StringifyNumbers(true) | Controls number stringification behavior. |
The full set of options is documented in the package reference. The important design principle is that behavior is explicit and configurable at the call site.
This is especially useful for libraries that need to support different API contracts without maintaining separate serialization implementations.
9. Performance: What Actually Gets Faster?
Performance is one of the most interesting parts of the new implementation, but it is worth reading the benchmark claims carefully.
The Go team's published results show that:
- Marshal performance is broadly comparable to v1. Depending on the workload, v2 can be somewhat faster or somewhat slower.
- Unmarshal performance is significantly faster in many cases, with benchmark improvements of up to 10x.
- Custom types can benefit substantially from the new streaming interfaces.
The phrase "up to 10x" is important. It describes the best improvements observed in particular benchmarks, not a universal speedup for every application.
9.1 Why Unmarshal Can Be Faster
The new architecture separates syntax processing from semantic conversion. That gives the implementation more opportunities to avoid repeated work and to process JSON more efficiently.
The new custom interfaces also remove some of the constraints imposed by the old []byte-based methods.
With UnmarshalJSON, a custom type receives a complete JSON value. The package may need to scan the input to determine the value's boundaries, then the custom implementation parses it again.
With UnmarshalJSONFrom, the custom implementation can consume JSON through the decoder. This can avoid an extra full-value parsing step.
9.2 Recursive Custom Unmarshalers Can Be Expensive
Consider a custom type that recursively calls json.Unmarshal for nested values.
That pattern can repeatedly parse overlapping portions of a document. In deeply nested structures, the work can grow much more quickly than expected.
The Go team describes a production case involving Kubernetes OpenAPI specifications where moving from recursive UnmarshalJSON processing to UnmarshalJSONFrom produced a very large performance improvement.
This is a good reminder that a serialization benchmark should resemble the application's real data and custom types. A benchmark containing only simple structs may not reveal the benefits of the new custom interfaces.
9.3 Benchmark Your Own Workload
Before changing a production service, measure its actual behavior.
A small benchmark can compare v1 and v2:
1func BenchmarkUnmarshalV1(b *testing.B) { 2 data := []byte(`{id:42,name:Alice}`) 3 4 b.ReportAllocs() 5 6 for i := 0; i < b.N; i++ { 7 var user User 8 if err := jsonv1.Unmarshal(data, &user); err != nil { 9 b.Fatal(err) 10 } 11 } 12} 13 14func BenchmarkUnmarshalV2(b *testing.B) { 15 data := []byte(`{id:42,name:Alice}`) 16 17 b.ReportAllocs() 18 19 for i := 0; i < b.N; i++ { 20 var user User 21 if err := jsonv2.Unmarshal(data, &user); err != nil { 22 b.Fatal(err) 23 } 24 } 25}
Run the benchmarks several times:
1go test -bench=Unmarshal -benchmem -count=5
Pay attention to:
ns/op: time per operation.B/op: bytes allocated per operation.allocs/op: allocations per operation.
For more reliable comparisons, use the same Go version, machine, input data, and benchmark setup. Tools such as benchstat can help compare repeated benchmark runs.
Do not assume that changing the import path alone will produce a meaningful improvement. The result depends on the shape of the data, the destination types, custom methods, and how the application uses the decoded values.
10. Compatibility Between v1 and v2
A major advantage of the new design is that v1 and v2 are intended to work together.
A dependency may define a type that implements the original MarshalJSON method. A newer application can still use that type with encoding/json/v2.
Likewise, a type can implement the new MarshalJSONTo method and still be used by code that calls the original encoding/json API.
The old package is implemented on top of the new implementation using a set of compatibility options. This means the standard library does not need to maintain two entirely separate serialization engines.
The compatibility promise also remains in place for the original package. Existing applications can continue using encoding/json indefinitely.
There is no requirement to migrate just because v2 is available.
11. Migrating an Existing Application
Changing the import path is often easy:
1import json encoding/json/v2
Basic calls such as json.Marshal and json.Unmarshal will look familiar.
The risky part is not the syntax of the migration. It is the change in behavior.
An application may rely on details that were never obvious until they change:
- A nil slice may need to remain
null. - A client may send field names with unexpected capitalization.
- Duplicate object names may appear in existing input.
- A custom unmarshaler may rely on the old byte-slice API.
- A response may be consumed by clients that distinguish
nullfrom an empty array. - A struct tag may have relied on v1's
omitemptybehavior.
A production migration should therefore be treated as a behavior change, not merely an import cleanup.
11.1 Start with Tests
Before changing the package, add tests around important JSON contracts.
Include cases for:
- Nil and empty slices.
- Nil and empty maps.
- Zero numeric values and
false. - Missing fields.
- Unknown fields.
- Case variations in member names.
- Duplicate member names.
- Invalid UTF-8.
- Custom marshalers and unmarshalers.
- Nested and large payloads.
Golden tests can be useful when exact serialized output is part of an external contract.
11.2 Migrate with v1-Compatible Defaults
For a large application, an all-at-once behavior change may be unnecessary.
The v2 package provides DefaultOptionsV1, which selects the old package's behavior for the relevant compatibility options.
1import ( 2 jsonv1 encoding/json 3 jsonv2 encoding/json/v2 4) 5 6data, err := jsonv2.Marshal( 7 user, 8 jsonv1.DefaultOptionsV1(), 9) 10if err != nil { 11 return err 12}
This allows an application to move to the new API while initially preserving v1 behavior.
Individual v2 behaviors can then be enabled deliberately. Later options override earlier ones.
For example, an application can preserve most v1 behavior while changing how nil slices are formatted:
1data, err := jsonv2.Marshal( 2 user, 3 jsonv1.DefaultOptionsV1(), 4 jsonv2.FormatNilSliceAsNull(false), 5)
This is useful when the team wants to isolate behavior changes and verify their impact independently.
11.3 Use jsonsplit for Production Investigation
The official migration guide also describes jsonsplit, a tool that can help identify which v1 and v2 behavior differences affect an application.
For a production service with many JSON call sites, this can be more practical than trying to reason about every possible compatibility difference manually.
The broader lesson is simple: migrate based on observed behavior and tests, not assumptions.
12. When Should You Use jsontext Directly?
Most applications should start with encoding/json/v2.
It provides the high-level mapping between Go types and JSON and is the natural replacement for common encoding/json use cases.
Use encoding/json/jsontext directly when you need lower-level control, such as:
- Building a custom JSON parser.
- Reading or writing JSON token by token.
- Transforming JSON without mapping the entire document into structs.
- Implementing streaming custom serialization.
- Preserving or inspecting details of JSON syntax.
- Building tools that operate on JSON values independently of application types.
There is no reason to use the lower-level package for every ordinary API handler. A simple request DTO is still a good use case for jsonv2.Unmarshal.
Choose the lowest level that solves the actual problem. More control is useful only when the application needs it.
13. Practical Example: A Small HTTP Handler
Here is what a simple HTTP handler might look like with the new package.
1package main 2 3import ( 4 io 5 net/http 6 7 json encoding/json/v2 8) 9 10type CreateUserRequest struct { 11 Name string `json:name` 12 Age int `json:age` 13} 14 15type CreateUserResponse struct { 16 ID int64 `json:id` 17 Name string `json:name` 18} 19 20func createUser(w http.ResponseWriter, r *http.Request) { 21 defer r.Body.Close() 22 23 var req CreateUserRequest 24 25 if err := json.UnmarshalRead( 26 r.Body, 27 &req, 28 json.RejectUnknownMembers(true), 29 ); err != nil { 30 http.Error(w, invalid request body, http.StatusBadRequest) 31 return 32 } 33 34 // Application logic would create and persist the user here. 35 response := CreateUserResponse{ 36 ID: 42, 37 Name: req.Name, 38 } 39 40 w.Header().Set(Content-Type, application/json) 41 42 if err := json.MarshalWrite(w, response); err != nil { 43 // The response may already be partially written. 44 // Production code should log the error and handle it 45 // according to the HTTP response lifecycle. 46 _ = err 47 return 48 } 49}
This example highlights two useful APIs:
UnmarshalReadreads from anio.Reader.MarshalWritewrites directly to anio.Writer.
It also demonstrates how options can be supplied at the call site.
For a production HTTP API, remember that JSON decoding is only one part of request validation. You still need to enforce request size limits, validate business rules, handle content types appropriately, and decide how to report errors.
14. What This Means for Go Backend Developers
The introduction of encoding/json/v2 is a good example of how the Go standard library can evolve without abandoning its compatibility philosophy.
The original API remains supported, but the new package provides room to correct old design decisions.
For backend developers, the most relevant improvements are:
- More predictable input handling. Invalid UTF-8 and duplicate names are rejected by default.
- Clearer JSON contracts. Case-sensitive matching and more explicit omission rules reduce surprises.
- Better customization. Options are available across the main API, and custom types can participate in the new encoder and decoder pipeline.
- More capable streaming APIs. The
jsontextpackage enables lower-level incremental processing. - A path to better performance. Unmarshal performance improves significantly in many workloads, particularly when custom types adopt the streaming interfaces.
- A gradual migration strategy. Compatibility options allow teams to adopt the new API without changing every behavior at once.
The biggest takeaway is that v2 is not just a faster version of json.Marshal. It is a more deliberate design for JSON processing in Go.
If you maintain a service that handles a lot of JSON, it is worth testing v2 against your real payloads. Even if you do not migrate immediately, understanding the new semantics will help you make better decisions about API contracts, custom serialization, and memory usage.
15. A Small Note on Reliable Go Deployments
Improving serialization is only one part of building reliable backend services. The way an application is built, deployed, and rolled back matters just as much.
That is one of the problems we are working on with Phelix.
Phelix is a deployment tool for Go and Rust applications, designed around zero-downtime deployments without requiring Kubernetes. It focuses on practical deployment workflows for teams that want a simpler path from a built binary to a running production service.
If you are building Go services and want to keep deployment infrastructure straightforward, take a look at phelix.anophel.com.
