A JSON decoder can accept a payload that another service interprets differently. Duplicate object names, unexpected capitalization, and invalid UTF-8 are not merely formatting quirks when the data controls an authorization decision or updates a record. A parser that quietly tolerates them can leave two components disagreeing about what the request meant.
That is one of the problems the Go team is addressing with encoding/json/v2, now available in Go 1.27. The new package is not just a faster spelling of the old one. It has stricter defaults, options for configuring behavior, and a lower-level package for JSON syntax. The original encoding/json remains supported, so upgrading does not require an all-at-once rewrite.[1][2]
This is a practical, test-first migration guide, not a benchmark or a report from a production migration. The code examples follow the current Go package documentation, but I have not run them in a local Go toolchain for this article. Start with the tests in your own module before changing imports.
First, understand what changed
Go 1.27 introduces two related packages:
-
encoding/json/v2handles the semantic mapping between JSON values and Go values. Its familiarMarshalandUnmarshalfunctions accept options. -
encoding/json/jsontextworks at the syntax level, with token and value APIs for applications that need to process JSON structure directly.[1][3]
For ordinary calls, migration can look deceptively small. The migration guide says many simple uses continue to compile after switching the import path.[2] But the defaults differ in ways that can change what inputs your service accepts and what output it emits. That behavior, not the import edit, is the real migration.
The important differences include:
- Object names are case-sensitive by default. The old package used a loose case-insensitive match when decoding object names into struct fields. V2 defaults to strict matching.[3]
- Duplicate object names are rejected by default. V1 accepted them, leaving interpretation to its processing behavior. V2 rejects them unless configured otherwise.[1][3]
- Invalid UTF-8 is rejected by default. V1 replaced invalid bytes with the Unicode replacement character. V2 takes the stricter path unless the corresponding syntax option is enabled.[3]
-
Nil slices and maps encode as empty JSON arrays and objects by default. If an existing API depends on
null, v2 provides options to preserve that output.[3] - Unknown members are still ignored by default. You can opt into rejecting them when that is the contract you want.[3]
These are not all “breaking changes” in the Go compatibility sense. They are differences to evaluate when you deliberately adopt the new package. The Go team says the old encoding/json API will continue to be supported, and existing behavior is preserved there, although exact error text can differ under Go 1.27 because its implementation is backed by v2.[1]
Step 1: Record the current contract
Before changing a package import, write tests for the behavior your API currently promises. Avoid treating current output as accidental until you have checked clients, snapshots, generated SDKs, and stored JSON.
A small request type makes the examples concrete:
type CreateAccountRequest struct {
DisplayName string `json:"displayName"`
Roles []string `json:"roles"`
}
The fields are illustrative. Replace them with a type from your own service and add tests for the actual boundary that receives JSON. At minimum, test:
- The exact field spelling clients send, including capitalization variants.
- Whether unknown fields are ignored or rejected.
- Whether duplicate names are possible in requests you receive and how they should be handled.
- Whether a nil slice or map is serialized as
nullor an empty collection. - Custom
MarshalJSONorUnmarshalJSONmethods used by nested types. - Invalid UTF-8 and malformed input, if your application has a policy for them.
This test inventory gives you a compatibility map. It also prevents an import diff from being mistaken for a complete migration review.
Step 2: Add a v2 decode path deliberately
The basic shape remains familiar. V2's options let a call choose behavior explicitly:
package api
import (
"encoding/json/v2"
)
type CreateAccountRequest struct {
DisplayName string `json:"displayName"`
Roles []string `json:"roles"`
}
func decodeCreateAccount(data []byte) (CreateAccountRequest, error) {
var req CreateAccountRequest
err := json.Unmarshal(data, &req, json.RejectUnknownMembers(true))
return req, err
}
The example opts into rejecting unknown members. That can catch misspelled fields and outdated clients, but it can also reject payloads that older versions intentionally tolerated. Apply it only where strict schema behavior is appropriate, and test older clients before enabling it on a public endpoint.[3]
V2 uses case-sensitive field matching by default. If compatibility requires case-insensitive matching, the package exposes an explicit option:
err := json.Unmarshal(data, &req, json.MatchCaseInsensitiveNames(true))
Treat this as a compatibility choice, not a universal fix. A service that accepts both displayName and DisplayName may be more forgiving, but broader matching can create ambiguity across systems that do not share the same rules.[2][3]
One useful migration strategy is to keep the existing decode function in place and introduce a second path behind a small boundary. Run the same fixture corpus through both implementations. Compare successful values and errors, then decide which differences are intended. Avoid silently falling back from v2 to v1 for every error: that can erase the stricter behavior you adopted and make malformed inputs hard to reason about.
Step 3: Make output shape explicit
Nil collections deserve particular attention because clients often distinguish null from [] or {}. In v2, a nil slice is encoded as an empty array by default. If a particular contract needs null, use the explicit formatting option:
out, err := json.Marshal(req, json.FormatNilSliceAsNull(true))
There is a corresponding option for nil maps, json.FormatNilMapAsNull(true).[3] Prefer defining the intended API shape in tests rather than adding options everywhere preemptively. A service may have one endpoint where empty means “no roles” and another where null means “not provided.” Those semantics should be visible in the type or handler design, not left to an undocumented global assumption.
V2 also provides omitzero behavior for struct fields. It is based on the Go value's zero-ness, including an IsZero method when available. That differs from omitempty, which is defined in terms of the JSON representation. For example, a non-nil empty slice can be omitted by omitempty, while omitzero focuses on whether the Go value itself is zero.[3] Changing tags can therefore alter which fields appear in a response. Add exact JSON output tests before changing a tag on a shared response type.
Step 4: Check the security-sensitive inputs
Duplicate names are a particularly important test case. Consider an object that includes the same key twice with different values. If a gateway, logger, and application parser do not resolve that object identically, they may act on different values. The Go announcement explains why v2 rejects duplicate names by default: the JSON specification does not assign them a single universal meaning, and inconsistent interpretation can be security-relevant.[1]
The safe default is usually to reject ambiguous input at the boundary. If a legacy partner sends duplicates, investigate the source and document an explicit temporary compatibility rule rather than assuming every downstream parser will make the same choice. V2's syntax package has options that can permit duplicate names, but allowing them should be a deliberate exception.[3]
Case sensitivity is another boundary issue. If one service treats userId and UserID as equivalent while another does not, a request can have different meanings at different hops. V2's default strict matching makes the accepted contract clearer. If your API has historically accepted variations, use tests to enumerate them and decide whether to preserve them intentionally.[2][3]
Step 5: Test custom types and error handling
An import replacement can expose assumptions inside custom JSON methods. The v2 package defines newer interfaces, including MarshalerTo and UnmarshalerFrom, and also supports the familiar Marshaler and Unmarshaler methods.[3] Review custom implementations that depend on encoder state, nested values, or options before assuming they behave identically.
Do not make tests depend on exact error strings unless that text is part of a deliberate user-facing contract. Go 1.27's release notes say the original encoding/json is backed by the v2 implementation, preserving its marshaling and unmarshaling behavior while allowing exact error messages to differ.[1] Prefer asserting that an error occurs and checking structured details only when the API provides a stable way to do so.
For stream processing, evaluate the new APIs separately from a simple Unmarshal replacement. V2 includes reader and writer functions, while jsontext provides lower-level syntax processing.[3] Choose the simplest API that meets the boundary requirements. A token API is useful when the application needs to inspect or transform JSON structure without first mapping the whole document to a Go struct; it is unnecessary complexity for a small request DTO.
Step 6: Roll out with a small compatibility matrix
A safe rollout can be staged:
- Capture fixtures. Use representative valid requests and responses from tests, logs that are safe to retain, and client examples. Remove secrets and personal data first.
- Run both decoders in tests. Compare values, accepted inputs, and rejection behavior for each fixture.
- Classify every difference. Mark it as intentional tightening, required compatibility, or an unexpected regression. Do not paper over the third category with a broad permissive option.
- Check serialized output. Compare exact JSON where clients or snapshots depend on field presence, null handling, or formatting.
- Migrate one boundary. Start with a low-risk endpoint or internal message type rather than changing every package at once.
- Observe rejection changes. During rollout, monitor error categories and client versions without logging sensitive payload contents.
- Keep rollback simple. A small wrapper around the decode operation makes it easier to revert a boundary if real clients depend on behavior your fixtures missed.
The Go migration guide notes that encoding/json and encoding/json/v2 can interoperate, including when types implement v2 custom marshal behavior.[2] That gives teams room to migrate incrementally. It does not remove the need to check each type's tags, custom methods, and external contract.
When should you migrate?
Use v2 for new code when your module targets Go 1.27 or later and the stricter defaults fit the contract. The option-based API makes decisions such as rejecting unknown fields explicit at the call site.
Consider a gradual migration when an existing service needs stricter input handling, the JSON boundary is security-sensitive, or you want the new streaming and customization APIs. Start with tests and one boundary at a time.
Stay on the old import for now when you cannot validate client compatibility, your module supports toolchains before Go 1.27, or your custom JSON behavior has not been inventoried. The old package is not being removed, so there is no need to rush a mechanical migration.[1][2]
The key change is not that every Go service must replace encoding/json this week. It is that Go now offers a standard-library path where stricter JSON semantics and compatibility exceptions can be expressed directly. Use the defaults to make ambiguous input less ambiguous, and use options only where a real contract requires them.
Migration checklist to save
- Pin the Go toolchain version your module supports.
- Add tests for case variations, duplicate names, unknown fields, and nil collections.
- Inventory custom JSON methods and struct tags before switching imports.
- Decide whether each endpoint should reject unknown fields.
- Compare exact serialized output for public response types.
- Treat compatibility options as documented exceptions.
- Roll out at one request or message boundary before migrating broadly.
I write about developer tools, backend engineering, and AI systems. Subscribe for more practical guides. Have you started evaluating encoding/json/v2? Which behavior would be most likely to surprise your existing clients?
Sources
[1] Go 1.27 Release Notes: JSON packages
[2] Go encoding/json/v2 Migration Guide
Top comments (0)