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:

Go
import "encoding/json"

Code may contain calls such as:

Go
data, err := json.Marshal(value)

err := json.Unmarshal(data, &value)

The v2 package uses:

Go
import "encoding/json/v2"

and keeps familiar operations:

Go
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:

  1. Source-code changes.
  2. Behavioral differences.
  3. Tests and production validation.

Step 1: Identify JSON Usage in the Application

Before changing code, find where the application uses encoding/json.

Look for:

Go
import "encoding/json"

and calls such as:

Go
json.Marshal(...)
json.Unmarshal(...)
json.NewEncoder(...)
json.NewDecoder(...)

Also search for custom implementations:

Go
MarshalJSON()
UnmarshalJSON()

and JSON-related struct tags:

Go
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:

Go
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:

Go
data, err := jsonutil.Marshal(user)

and:

Go
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:

Go
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:

Go
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:

Go
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:

Go
json:"first_name"

to:

Go
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:

Go
type User struct {
	UserName string `json:"username"`
}

An external service may send:

JSON
{
	"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:

JSON
{
	"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:

Go
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:

Go
type Product struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
}

The API may later send:

JSON
{
	"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:

Go
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:

Code
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:

Go
decoder := json.NewDecoder(reader)
err := decoder.Decode(&value)

or:

Go
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 AreaRisk LevelWhat to Check
Basic MarshalLowJSON output compatibility
Basic UnmarshalMediumInput validation and field matching
Struct tagsMediumProperty names and options
Duplicate propertiesHighExisting external payloads
Invalid UTF-8HighExternal data sources
Unknown propertiesMediumAPI compatibility
Custom MarshalJSONHighBusiness serialization rules
Custom UnmarshalJSONHighValidation and conversion
Encoder/DecoderMediumStreaming behavior
Large payloadsMediumMemory and performance
Third-party APIsHighReal 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:

Go
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

  1. Inventory existing JSON usage.
  2. Separate internal JSON processing from external API boundaries.
  3. Select one small package for the initial migration.
  4. Keep existing tests and add edge-case tests.
  5. Compare real application payloads against both implementations.
  6. Review custom marshaling and unmarshaling code.
  7. Test duplicate properties and invalid input.
  8. Review unknown-property behavior.
  9. Benchmark representative payloads.
  10. 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:

Code
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.