JSON is one of the most common boundaries in a Go application. REST APIs, configuration files, event payloads, message queues, webhooks, and third-party integrations often depend on the behavior of encoding/json.

Go 1.27 introduces encoding/json/v2, a major revision of the JSON API with stricter defaults, configurable options, and new streaming capabilities. At the same time, the existing encoding/json package remains supported and is now implemented using the v2 engine while preserving its historical behavior.

That distinction is important.

Moving an existing application from:

import "encoding/json"

to:

import "encoding/json/v2"

may require only a small source-code change, but the resulting JSON can behave differently.

For a new application, those stricter defaults may be exactly what you want. For an established API, however, changing JSON behavior without testing can break clients, snapshots, signatures, caches, or database integrations.

This article explains how to evaluate encoding/json/v2, identify compatibility risks, test existing APIs, and migrate gradually.

What Changed in encoding/json/v2?

The v2 package introduces a new API with configurable options.

Basic marshaling remains familiar:

package main

import (
	"fmt"

	"example.com/project"
	jsonv2 "encoding/json/v2"
)

func main() {
	user := project.User{
		ID:   1001,
		Name: "Alice",
	}

	data, err := jsonv2.Marshal(user)
	if err != nil {
		panic(err)
	}

	fmt.Println(string(data))
}

The important difference is that v2 does not simply reproduce every historical behavior of encoding/json.

It intentionally uses stricter and more interoperable defaults.

Some important differences include:

Behavior

encoding/json

encoding/json/v2

Invalid UTF-8

Replaced with replacement character

Rejected by default

Duplicate JSON object names

Accepted

Rejected by default

Nil slice marshaling

null

Empty array by default

Nil map marshaling

null

Empty object by default

Struct field matching

Historically case-insensitive

Case-sensitive by default

Configuration

Historically limited

Extensive Options support

Streaming

Encoder/Decoder

MarshalWrite, UnmarshalRead, and jsontext APIs

These are not merely implementation details. They can change the wire format or whether a request is accepted.

The Existing encoding/json Package Is Not Going Away

One of the most important migration facts is that you do not have to replace encoding/json.

The Go compatibility promise continues to apply to it.

Go 1.27 changes its implementation internally, but the package preserves its historical marshaling and unmarshaling behavior through v2 compatibility options.

This means an existing application can continue using:

import "encoding/json"

without immediately migrating to:

import "encoding/json/v2"

That makes migration a design decision rather than an emergency upgrade requirement.

Why Should You Consider encoding/json/v2?

There are several reasons.

Stricter JSON validation

V2 rejects invalid UTF-8 rather than silently replacing invalid bytes.

This is important when JSON crosses trust boundaries.

For example, an API receiving malformed text should generally be able to distinguish invalid input from valid data containing the replacement character.

Duplicate JSON names are rejected

Consider:

{
  "id": 10,
  "name": "Alice",
  "name": "Bob"
}

The JSON specification does not require all implementations to handle duplicate names in the same way.

V2 rejects duplicate names by default, which removes ambiguity at the parsing boundary.

Better streaming APIs

V2 provides:

jsonv2.MarshalWrite(...)
jsonv2.UnmarshalRead(...)

This makes working directly with io.Writer and io.Reader more straightforward.

For example:

func writeUser(w io.Writer, user User) error {
	return jsonv2.MarshalWrite(w, user)
}

This can be cleaner than creating an encoder solely for straightforward JSON output.

More explicit configuration

V2 exposes options for controlling serialization and deserialization behavior.

That allows an application to deliberately choose compatibility behavior instead of depending entirely on historical defaults.

The Biggest Migration Risk: Output That Looks Almost the Same

A dangerous migration is one that compiles successfully.

Suppose you have:

type User struct {
	Name      string
	Nicknames []string
}

And:

user := User{
	Name: "Alice",
}

With the traditional package, a nil slice can be encoded as:

{
  "Name": "Alice",
  "Nicknames": null
}

With v2's defaults, the same nil slice can become:

{
  "Name": "Alice",
  "Nicknames": []
}

Both are valid JSON.

Your Go compiler will not complain.

But a client might distinguish between:

"Nicknames": null

and:

"Nicknames": []

That makes this a compatibility problem rather than a compilation problem.

Build a Compatibility Test Before Migrating

Before changing production code, capture representative JSON behavior from the existing implementation.

Suppose your application exposes:

type Product struct {
	ID       int      `json:"id"`
	Name     string   `json:"name"`
	Tags     []string `json:"tags"`
	Metadata map[string]string `json:"metadata"`
}

Create test fixtures:

var product = Product{
	ID:   100,
	Name: "Keyboard",
}

Then record the current output:

func TestProductJSONCompatibility(t *testing.T) {
	data, err := json.Marshal(product)
	if err != nil {
		t.Fatal(err)
	}

	t.Log(string(data))
}

Do not immediately replace the import.

First understand what your application currently promises to clients.

Compare V1 and V2 Side by Side

A useful migration test is to serialize the same value using both implementations.

func TestJSONCompatibility(t *testing.T) {
	value := Product{
		ID:   100,
		Name: "Keyboard",
	}

	v1Data, err := jsonv1.Marshal(value)
	if err != nil {
		t.Fatal(err)
	}

	v2Data, err := jsonv2.Marshal(value)
	if err != nil {
		t.Fatal(err)
	}

	t.Logf("v1: %s", v1Data)
	t.Logf("v2: %s", v2Data)
}

This makes behavioral differences visible before they reach an API client.

However, do not compare raw JSON strings blindly.

JSON object ordering and formatting should not generally be treated as API semantics.

When appropriate, decode the results and compare their semantic structures.

Test the Wire Contract, Not Just Go Values

Suppose your API historically returns:

{
  "id": 100,
  "tags": null
}

A test that only verifies:

var product Product

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

may still pass after migration.

But the external client may receive:

{
  "id": 100,
  "tags": []
}

The Go type looks equivalent.

The API contract is not necessarily equivalent.

For public APIs, test the actual serialized representation.

For example:

func TestProductResponse(t *testing.T) {
	data, err := jsonv2.Marshal(Product{
		ID:   100,
		Name: "Keyboard",
	})

	if err != nil {
		t.Fatal(err)
	}

	got := string(data)

	if !strings.Contains(got, `"id":100`) {
		t.Fatalf("unexpected JSON: %s", got)
	}
}

For more complex APIs, use golden files or structured JSON comparisons rather than fragile substring checks.

Use Golden Tests Carefully

Golden files can be useful when the serialized format is itself part of the contract.

Example:

testdata/
    product.json
    order.json
    customer.json

A test can compare the generated output against the expected representation.

func TestProductJSONGolden(t *testing.T) {
	data, err := jsonv2.Marshal(product)
	if err != nil {
		t.Fatal(err)
	}

	expected, err := os.ReadFile("testdata/product.json")
	if err != nil {
		t.Fatal(err)
	}

	if !bytes.Equal(data, expected) {
		t.Fatalf("JSON output changed")
	}
}

Golden tests are especially valuable for:

But update golden files deliberately. A changed snapshot is evidence of a behavior change, not automatically something that should be accepted.

Test Nil Slices and Maps Explicitly

One of the easiest compatibility problems to miss is the distinction between null and empty collections.

Consider:

type Response struct {
	Items map[string]string `json:"items"`
	Tags  []string          `json:"tags"`
}

Test both nil and initialized values:

func TestCollectionEncoding(t *testing.T) {
	tests := []struct {
		name string
		data Response
	}{
		{
			name: "nil collections",
			data: Response{},
		},
		{
			name: "empty collections",
			data: Response{
				Items: map[string]string{},
				Tags:  []string{},
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			data, err := jsonv2.Marshal(tt.data)
			if err != nil {
				t.Fatal(err)
			}

			t.Log(string(data))
		})
	}
}

This catches differences that ordinary happy-path tests frequently miss.

Test Case Sensitivity

Field matching during unmarshaling is another important compatibility area.

Suppose the Go structure contains:

type User struct {
	UserID string `json:"userId"`
}

An existing application may have accepted variations in JSON member names because historical matching was case-insensitive.

V2 uses case-sensitive matching by default.

Therefore, test real payloads from clients instead of only generating JSON from your own Go structures.

For example:

func TestIncomingUser(t *testing.T) {
	input := []byte(`{
		"userid": "A100"
	}`)

	var user User

	if err := jsonv2.Unmarshal(input, &user); err != nil {
		t.Fatal(err)
	}
}

If your clients have historically sent inconsistent casing, this is exactly the kind of test that should be present before migration.

Test Duplicate JSON Fields

Existing clients or upstream systems may occasionally send duplicate names.

For example:

{
  "status": "pending",
  "status": "approved"
}

The old behavior may have allowed this.

V2 rejects duplicate names by default.

Add an explicit test:

func TestDuplicateFields(t *testing.T) {
	input := []byte(`{
		"status": "pending",
		"status": "approved"
	}`)

	var response struct {
		Status string `json:"status"`
	}

	err := jsonv2.Unmarshal(input, &response)

	if err == nil {
		t.Fatal("expected duplicate field error")
	}
}

If your application depends on accepting such payloads, do not discover this difference through a production incident.

Test Invalid UTF-8

V2 is stricter about UTF-8.

A test can make this behavior explicit:

func TestInvalidUTF8(t *testing.T) {
	input := []byte{
		'{', '"', 'n', 'a', 'm', 'e', '"', ':',
		'"', 0xff, '"',
		'}',
	}

	var value map[string]string

	err := jsonv2.Unmarshal(input, &value)

	if err == nil {
		t.Fatal("expected invalid UTF-8 error")
	}
}

This is particularly relevant for applications processing data from external systems.

Use DefaultOptionsV1 for a Gradual Migration

You do not necessarily have to choose between "keep everything on v1" and "change every behavior immediately."

The v2 API provides compatibility options.

A useful migration approach is to start with v2 while preserving v1 behavior:

data, err := jsonv2.Marshal(
	value,
	jsonv1.DefaultOptionsV1(),
)

This allows an application to move to the v2 API surface while initially retaining the historical behavior.

You can then change individual behaviors deliberately.

For example:

data, err := jsonv2.Marshal(
	value,
	jsonv1.DefaultOptionsV1(),
	jsonv2.FormatNilSliceAsNull(false),
)

The important principle is that later options can override earlier compatibility options.

This makes incremental migration possible.

A Safer Migration Strategy for APIs

For an existing production API, a staged approach is usually safer than a global replacement.

Step 1: Inventory JSON boundaries

Find where your application uses JSON:

rg 'encoding/json'

Then classify each usage:

Not every usage has the same compatibility risk.

Step 2: Capture existing behavior

Create tests around important payloads.

Pay particular attention to:

Step 3: Run v1 and v2 comparisons

Serialize representative objects using both implementations.

For inbound payloads, attempt to decode the same fixtures using both implementations.

Step 4: Identify intentional differences

Do not automatically eliminate every difference.

Some v2 changes may be improvements.

For example, rejecting invalid UTF-8 may be preferable to silently modifying input.

The important question is:

Is the new behavior compatible with the contract this application needs to maintain?

Step 5: Migrate low-risk components first

Internal tooling is usually easier to migrate than a public API.

Use the experience from low-risk services to identify unexpected compatibility problems.

Step 6: Migrate public boundaries deliberately

For externally consumed APIs, explicitly document and test the expected JSON contract.

Do not assume that a successful compilation means the migration is safe.

Custom JSON Implementations Need Special Attention

Applications often define custom marshaling behavior.

For example:

type UserID int64

func (id UserID) MarshalJSON() ([]byte, error) {
	return []byte(strconv.FormatInt(int64(id), 10)), nil
}

Before migrating, test these custom types independently.

Also check whether your custom implementations depend on historical behavior from encoding/json.

The v2 API introduces new interfaces and extension points, so simply changing imports may not always be the best long-term design for complex serialization code.

jsontext for Lower-Level JSON Processing

Go 1.27 also introduces encoding/json/jsontext.

Where encoding/json/v2 provides higher-level semantic processing, jsontext focuses on JSON syntax.

It provides encoder and decoder types that work with JSON tokens and values.

This is useful when an application needs more control over the JSON stream than ordinary marshaling provides.

For most REST APIs, you probably do not need jsontext.

But it becomes interesting for:

Do not introduce it simply because it is new. Use it when your application actually needs syntactic-level control.

Troubleshooting Common Migration Problems

The application compiles but clients break

This usually indicates a behavioral difference rather than an API difference.

Compare actual JSON payloads from v1 and v2.

Start with:

Unmarshal now returns an error

Check whether the input contains:

Do not immediately weaken validation. Determine whether the input is actually valid according to the contract you want.

Snapshot tests fail

First determine whether the difference is:

If only formatting changed and formatting is not part of your contract, use a semantic JSON comparison.

A third-party client rejects the new response

Compare the old and new wire payloads.

A client may depend on distinctions such as:

null

versus:

[]

or on a particular field-matching convention.

In this situation, preserve the existing contract or version the API rather than silently breaking clients.

Should You Migrate Every encoding/json Import?

No.

A blanket replacement such as:

encoding/json

to:

encoding/json/v2

across an entire repository is not a migration strategy.

It is a source-code change that happens to trigger behavioral changes.

A better approach is to evaluate each boundary.

Application area

Migration priority

New service

High

New internal API

High

New public API

High

Existing internal tool

Medium

Existing public API

Carefully tested

Third-party compatibility layer

Very carefully

Historical data serialization

Very carefully

Tests only

Low

The more consumers depend on the exact JSON representation, the more important compatibility testing becomes.

Best Practices

Treat JSON as an API contract

Do not assume two valid JSON documents are automatically interchangeable.

Test real payloads

Use payloads captured from production contracts where appropriate and permitted.

Compare behavior before changing imports

Compilation proves API compatibility, not wire compatibility.

Prefer incremental migration

Start with low-risk boundaries and move toward externally consumed APIs after testing.

Use compatibility options intentionally

DefaultOptionsV1 provides a practical starting point for gradually adopting the v2 API.

Keep public API tests

If clients depend on your JSON format, keep contract tests that verify the actual serialized representation.

Do not disable strict behavior without understanding why

If v2 rejects malformed input, investigate the source of that input before simply restoring permissive behavior.

Test both directions

Do not only test:

Go object -> JSON

Also test:

JSON -> Go object

Many migration problems occur during unmarshaling rather than marshaling.

Advantages of encoding/json/v2

Stricter defaults

Invalid UTF-8 and duplicate JSON names are rejected by default.

Better interoperability

The defaults are designed to reduce surprising behavior across JSON implementations.

Configurable behavior

Options allow applications to explicitly choose serialization semantics.

Improved streaming APIs

MarshalWrite and UnmarshalRead simplify direct interaction with readers and writers.

Faster unmarshaling

Go's release documentation reports that unmarshaling performance is significantly faster, while marshaling performance is broadly at parity with the previous implementation.

Disadvantages and Migration Risks

Behavioral differences

Existing applications may rely on v1 behavior.

More migration testing

Large APIs need representative payload coverage before switching behavior.

Client compatibility problems

External consumers may depend on details such as null versus empty collections.

Custom serialization complexity

Applications with extensive custom JSON logic require more careful migration.

Migration is not simply an import change

The source-code change may be small, but the behavioral change can be much larger.

Conclusion

Go 1.27's encoding/json/v2 is a substantial improvement to JSON handling, but existing applications should not treat it as a drop-in replacement without testing.

The most important distinction is this:

API compatibility != behavior compatibility

Changing:

import "encoding/json"

to:

import "encoding/json/v2"

may compile successfully while changing how your application handles nil collections, duplicate fields, invalid UTF-8, field matching, and other JSON semantics.

For new applications, adopting v2 deliberately can provide stricter and more interoperable defaults.

For existing APIs, start by capturing current behavior, compare representative payloads, test both marshaling and unmarshaling, and use compatibility options when a gradual transition is more appropriate.

The safest migration is not the one that changes the most code.

It is the one where every important behavioral change is understood, tested, and intentional.