Go applications often depend heavily on JSON without developers giving much thought to the serialization layer. A typical application may use JSON for REST APIs, configuration files, database records, background jobs, webhooks, and communication between microservices.
When moving from encoding/json to encoding/json/v2, the biggest mistake is treating the change as a simple import replacement.
The APIs look familiar, but some default behaviors are different. These differences can affect existing API clients, request handlers, message consumers, configuration readers, and custom JSON types.
The good news is that an existing application does not have to be migrated in one step. A controlled migration can keep the current implementation available while individual packages are moved and tested.
What Changes During the Migration?
The traditional implementation typically starts with:
import "encoding/json"
Code may contain calls such as:
data, err := json.Marshal(value)
err := json.Unmarshal(data, &value)
The v2 package uses:
import "encoding/json/v2"
and keeps familiar operations:
data, err := json.Marshal(value)
err := json.Unmarshal(data, &value)
This similarity makes the initial migration look simple.
The difficult part is compatibility.
The migration should therefore cover three areas:
- Source-code changes.
- Behavioral differences.
- Tests and production validation.
Step 1: Identify JSON Usage in the Application
Before changing code, find where the application uses encoding/json.
Look for:
import "encoding/json"
and calls such as:
json.Marshal(...)
json.Unmarshal(...)
json.NewEncoder(...)
json.NewDecoder(...)
Also search for custom implementations:
MarshalJSON()
UnmarshalJSON()
and JSON-related struct tags:
json:"name"
json:"id,omitempty"
Do not assume that every JSON operation is equally safe to migrate.
An internal configuration object and an external payment API response may have very different compatibility requirements.
Step 2: Create a Migration Boundary
A large application becomes easier to migrate when JSON handling is isolated behind a small number of functions or packages.
For example:
package jsonutil
import "encoding/json"
func Marshal(value any) ([]byte, error) {
return json.Marshal(value)
}
func Unmarshal(data []byte, value any) error {
return json.Unmarshal(data, value)
}
Application code can then use:
data, err := jsonutil.Marshal(user)
and:
err := jsonutil.Unmarshal(data, &user)
This is useful because the JSON implementation is no longer spread across every package.
During migration, the implementation can be changed in one place.
However, this wrapper should not become a large abstraction layer. Keep it small and focused on the application's actual requirements.
Step 3: Migrate a Small Package First
After identifying a suitable migration candidate, change only that package.
For example, an existing implementation may contain:
package profile
import "encoding/json"
type Profile struct {
ID int `json:"id"`
Username string `json:"username"`
}
func EncodeProfile(profile Profile) ([]byte, error) {
return json.Marshal(profile)
}
The migrated implementation becomes:
package profile
import "encoding/json/v2"
type Profile struct {
ID int `json:"id"`
Username string `json:"username"`
}
func EncodeProfile(profile Profile) ([]byte, error) {
return json.Marshal(profile)
}
The application logic does not need to change.
This is one reason incremental migration is practical.
The same function can continue to return []byte, while the serialization implementation changes internally.
Step 4: Review Struct Tags
Struct tags are often overlooked during JSON migrations.
For example:
type Customer struct {
ID int `json:"id"`
FirstName string `json:"first_name"`
Email string `json:"email,omitempty"`
}
Before migrating, review whether these tags represent the actual API contract.
Do not use the migration as an opportunity to casually rename JSON properties.
Changing:
json:"first_name"
to:
json:"firstName"
is an API behavior change, not simply a library migration.
Existing clients may depend on the original property name.
Keep unrelated changes separate from the migration whenever possible.
Step 5: Check Field Matching Behavior
One of the important compatibility areas is property-name matching.
Consider:
type User struct {
UserName string `json:"username"`
}
An external service may send:
{
"UserName": "Rahul"
}
If the existing application depends on case-insensitive matching, switching to stricter v2 behavior can change the result.
This is particularly important for applications consuming third-party APIs.
Before migrating, collect representative payloads and test them against the new implementation.
Step 6: Test Duplicate JSON Properties
Duplicate JSON properties are another important migration case.
Consider:
{
"id": 101,
"name": "Rahul",
"name": "Amit"
}
This JSON contains two values for the same property.
Applications should ideally never depend on duplicate property behavior. However, an existing system may already receive such data.
Create a test for these payloads before migration.
For example:
func TestDuplicateJSONProperties(t *testing.T) {
input := []byte(`{
"id": 101,
"name": "Rahul",
"name": "Amit"
}`)
var user User
err := json.Unmarshal(input, &user)
if err != nil {
t.Logf("JSON rejected: %v", err)
}
}
The goal of this test is not to force one specific result.
It is to identify whether the application's current data sources depend on behavior that changes during migration.
Step 7: Review Invalid UTF-8 Handling
Applications receiving external data should also test invalid UTF-8.
This is particularly important for systems that receive data from older services, imported files, or systems with inconsistent encoding behavior.
The migration should answer a simple question:
Should this application accept malformed text, or should it reject it?
If the answer is to reject malformed input, the stricter behavior may actually be useful.
If the application must remain compatible with an existing upstream system, the behavior needs to be handled deliberately.
Step 8: Review Unknown JSON Members
Suppose the application has:
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
}
The API may later send:
{
"id": 101,
"name": "Laptop",
"category": "Electronics"
}
The additional category property may be irrelevant to the application.
Whether unknown properties should be accepted or rejected depends on the API contract.
Strict validation can be useful for tightly controlled internal APIs, but it can also make clients fragile when external APIs add new properties.
Therefore, do not enable strict unknown-member rejection across the entire application without reviewing the API contracts first.
Step 9: Migrate Custom Marshalers
Custom JSON implementations require special attention.
For example:
type Money struct {
Amount float64
Currency string
}
func (m Money) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Value float64 `json:"value"`
Currency string `json:"currency"`
}{
Value: m.Amount,
Currency: m.Currency,
})
}
When migrating this code, verify both serialization and deserialization behavior.
Create tests that confirm:
Go object -> JSON
JSON -> Go object
produce the expected result.
Custom serialization often contains business rules, so it should not be changed simply because the JSON package is being upgraded.
Step 10: Review Encoder and Decoder Usage
Existing applications may use stream-based APIs such as:
decoder := json.NewDecoder(reader)
err := decoder.Decode(&value)
or:
encoder := json.NewEncoder(writer)
err := encoder.Encode(value)
These should be reviewed separately from simple Marshal and Unmarshal calls.
The new package provides reader and writer oriented APIs, so applications that process streams may have additional migration opportunities.
However, migrate only after verifying the expected behavior of the complete request or response pipeline.
Comparing the Migration Areas
| Migration Area | Risk Level | What to Check |
|---|
| Basic Marshal | Low | JSON output compatibility |
| Basic Unmarshal | Medium | Input validation and field matching |
| Struct tags | Medium | Property names and options |
| Duplicate properties | High | Existing external payloads |
| Invalid UTF-8 | High | External data sources |
| Unknown properties | Medium | API compatibility |
| Custom MarshalJSON | High | Business serialization rules |
| Custom UnmarshalJSON | High | Validation and conversion |
| Encoder/Decoder | Medium | Streaming behavior |
| Large payloads | Medium | Memory and performance |
| Third-party APIs | High | Real payload compatibility |
The exact risk depends on the application. The table should be treated as a starting point for investigation rather than a fixed migration score.
Performance Testing During Migration
Do not assume that changing JSON implementations will automatically improve the application's overall performance.
Measure the actual workload.
A simple benchmark can compare the two implementations:
func BenchmarkMarshal(b *testing.B) {
user := User{
ID: 101,
Username: "rahul",
}
for i := 0; i < b.N; i++ {
_, err := json.Marshal(user)
if err != nil {
b.Fatal(err)
}
}
}
A useful benchmark should use representative application data.
If the production application processes large nested structures, a tiny three-field object will not provide a meaningful comparison.
Measure the workloads that actually matter to the application.
Running Tests Before Production
A migration should pass several levels of testing.
Unit Tests
Verify individual serializers, deserializers, and data models.
Integration Tests
Verify complete API requests, responses, queue messages, and file-processing workflows.
Contract Tests
For applications communicating with external services, verify that expected JSON contracts remain compatible.
Regression Tests
Use existing production-shaped payloads to identify behavior changes.
Load Tests
If JSON processing is a significant part of the workload, compare CPU, memory, throughput, and latency under realistic traffic.
Common Migration Mistakes
Treating the Change as an Import Replacement
Changing the import path is only the first step.
The behavior of the application still needs to be tested.
Migrating Everything at Once
A complete repository migration creates a large change surface.
If something breaks, identifying the responsible package becomes harder.
Ignoring External Payloads
Unit tests written from your own Go structs may pass while real third-party payloads fail.
Changing API Contracts During Migration
Do not rename JSON fields, change null handling, or modify response formats as part of an unrelated library migration.
Keep the migration focused.
Removing Existing Tests
The old implementation's tests are valuable regression tests. Keep them while introducing the new implementation.
Best Practices for a Safe Migration
- Inventory existing JSON usage.
- Separate internal JSON processing from external API boundaries.
- Select one small package for the initial migration.
- Keep existing tests and add edge-case tests.
- Compare real application payloads against both implementations.
- Review custom marshaling and unmarshaling code.
- Test duplicate properties and invalid input.
- Review unknown-property behavior.
- Benchmark representative payloads.
- Migrate additional packages only after the first migration is stable.
This approach makes the change easier to review and easier to roll back.
Troubleshooting Migration Failures
When a migrated package starts failing, first identify whether the problem is related to source compatibility or JSON behavior.
If the code does not compile, check:
- Changed package APIs.
- Encoder and decoder usage.
- Custom interfaces.
- Updated function signatures.
- Removed or changed behavior.
If the code compiles but tests fail, inspect:
- JSON property casing.
- Duplicate properties.
- Invalid UTF-8.
- Unknown properties.
- Null handling.
- Custom marshaling.
- Custom unmarshaling.
- Numeric conversions.
If only external API tests fail, compare the exact payload received by the application with the payload used in unit tests.
Real payloads often reveal compatibility assumptions that are not visible in simple test data.
Advantages of Incremental Migration
Lower Risk
Only a small part of the application changes at a time.
Easier Testing
Failures can be associated with a specific package or service.
Easier Rollback
The existing JSON implementation can remain available during the migration.
Better Understanding of Compatibility
Teams can discover which external systems depend on permissive JSON behavior.
Controlled Performance Evaluation
Performance can be measured before and after the change using real workloads.
Disadvantages of Incremental Migration
Temporary Dual Implementations
Large applications may temporarily contain both JSON implementations.
Additional Testing
Each migrated package requires compatibility testing.
More Migration Planning
Teams need to identify suitable boundaries instead of making one large repository-wide change.
Mixed Behavior
Different packages may temporarily use different JSON implementations, which requires clear documentation.
A Practical Migration Strategy
For most existing applications, the migration can follow this sequence:
Inventory JSON usage
|
v
Select one isolated package
|
v
Create compatibility tests
|
v
Migrate to encoding/json/v2
|
v
Test real payloads
|
v
Benchmark representative workloads
|
v
Deploy with monitoring
|
v
Expand migration gradually
The important part is that each stage should provide enough evidence before moving to the next one.
Summary
Migrating from encoding/json to encoding/json/v2 is more than changing an import statement. The APIs are familiar, but stricter JSON behavior can affect existing applications that depend on permissive parsing or inconsistent external payloads.
A safe migration starts by identifying JSON usage, creating a clear migration boundary, and moving one small workload at a time.
Special attention should be given to duplicate JSON properties, invalid UTF-8, field-name matching, unknown properties, custom marshalers, custom unmarshalers, and streaming JSON operations.
Performance should also be measured using representative application payloads rather than relying on assumptions.
The best migration strategy is incremental. Keep the existing implementation available while testing json/v2 in controlled areas, preserve existing API contracts, and expand the migration only after compatibility and performance have been verified.
Join the conversation! Your thoughts help the community grow.