Introduction

Go 1.27 introduces encoding/json/v2, a major revision of JSON handling in the standard library. At the same time, the existing encoding/json package remains supported and is now implemented using the new JSON implementation underneath. Go 1.27 also preserves the historical behavior of encoding/json, so upgrading the Go toolchain does not automatically require an application to rewrite its JSON code.

The important distinction is between upgrading to Go 1.27 and migrating application code to encoding/json/v2.

Those are not the same operation.

For an existing production API, changing:

import "encoding/json"

to:

import "encoding/json/v2"

can change JSON behavior even when the code still compiles.

The main migration risks involve duplicate JSON object names, invalid UTF-8, nil slices and maps, field-name matching, struct tags, custom marshaling methods, and other behavioral differences.

This article explains what can break, how to test for those differences, and how to migrate without changing an API contract accidentally.

What Changed in Go 1.27?

Go 1.27 adds two new packages:

encoding/json/v2
encoding/json/jsontext

encoding/json/v2 provides the high-level JSON API, while encoding/json/jsontext provides lower-level JSON syntax and token processing.

The existing package remains:

encoding/json

and continues to be supported.

The relationship can be visualized as:

                 Go 1.27
                    |
          +---------+---------+
          |                   |
          v                   v
   encoding/json        encoding/json/v2
   Legacy semantics     New semantics
          |                   |
          +---------+---------+
                    |
                    v
             Shared implementation

The existing encoding/json package is backed by the new implementation while preserving its historical behavior through compatibility options.

This means a normal Go 1.27 upgrade should not suddenly change your JSON API contract.

Do You Have to Migrate?

No.

This is one of the most important points.

The Go team states that encoding/json will remain supported under Go's compatibility promise. Existing applications are not required to migrate to encoding/json/v2.

You can therefore upgrade:

Go 1.26
   |
   v
Go 1.27
   |
   v
Keep using encoding/json

without immediately changing every JSON call site.

Migration becomes a separate engineering decision.

Basic API Migration

A simple v1 call might look like:

package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    Name string `json:"name"`
}

func main() {
    user := User{Name: "Asha"}

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

    fmt.Println(string(data))
}

A basic v2 migration is structurally similar:

package main

import (
    jsonv2 "encoding/json/v2"
    "fmt"
)

type User struct {
    Name string `json:"name"`
}

func main() {
    user := User{Name: "Asha"}

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

    fmt.Println(string(data))
}

The API looks familiar.

The difficult part is not normally compilation.

The difficult part is behavior.

The Go migration guide explicitly notes that the main migration challenge is behavioral compatibility rather than basic API compatibility.

Difference 1: Duplicate JSON Object Names

This is one of the most important changes.

Consider:

{
  "name": "Asha",
  "name": "Priya"
}

JSON containing duplicate object names has historically been accepted by Go's v1 implementation.

The v2 defaults are stricter and reject duplicate names.

For an API that receives JSON from external clients, this can change behavior.

For example:

var request UserRequest

err := jsonv2.Unmarshal(body, &request)
if err != nil {
    return err
}

An input that previously reached application code may now return an error during decoding.

This is generally useful for interoperability and predictable parsing, but it can expose clients that were relying on permissive behavior.

Difference 2: Invalid UTF-8

JSON strings should contain valid UTF-8.

Historically, Go's v1 JSON behavior replaced invalid UTF-8 bytes with the Unicode replacement character.

The v2 implementation instead reports an error for invalid UTF-8.

This can matter when JSON is generated by:

  • Legacy systems

  • Native applications

  • Incorrectly configured middleware

  • Binary-to-text conversion code

  • External integrations

A migration can therefore turn previously accepted input into rejected input.

Test external integrations rather than assuming that all producers generate perfectly valid JSON.

Difference 3: Nil Slices and Maps

This is a particularly important difference for APIs.

Consider:

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

response := Response{
    Items: nil,
}

With historical encoding/json behavior, this produces:

{
  "items": null
}

Under v2 defaults, a nil slice is marshaled as an empty JSON array:

{
  "items": []
}

The migration guide identifies this as a deliberate behavioral difference.

That looks small, but API clients can distinguish:

null

from:

[]

For example:

if (response.items === null) {
    // No value
}

is not equivalent to:

if (response.items.length === 0) {
    // Empty collection
}

Before migrating a public API, search for fields where null versus an empty collection is part of the contract.

Difference 4: Nil Maps

The same issue applies to maps.

A nil map historically becomes:

null

while v2 defaults can encode it as:

{}

For example:

type Response struct {
    Metadata map[string]string `json:"metadata"`
}

With:

Response{
    Metadata: nil,
}

the JSON representation can change from:

{
  "metadata": null
}

to:

{
  "metadata": {}
}

Again, this can affect clients even though the Go structure itself has not changed.

Difference 5: Case-Sensitive Field Matching

Another important change occurs during unmarshaling.

Historically, encoding/json uses case-insensitive matching when mapping JSON object names to Go struct fields.

For example:

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

An older implementation could accept variations such as:

{
  "username": "asha"
}

and:

{
  "UserName": "asha"
}

v2 defaults use exact, case-sensitive matching instead. The compatibility options allow applications to retain case-insensitive behavior where required.

This is especially important when an API has clients that use inconsistent casing.

Difference 6: Struct Tag Behavior

JSON struct tags are central to many Go applications.

For example:

type User struct {
    ID        int    `json:"id"`
    FirstName string `json:"first_name"`
}

Go 1.27 introduces additional JSON options and tag behavior associated with v2.

Some behavior changed during the development of JSON v2. For example, the experimental inline tag option was renamed to embed, and other experimental options were removed or changed before the Go 1.27 release.

This is one reason not to copy examples written against early JSON v2 experiments directly into a Go 1.27 codebase.

Always validate examples against the released Go version.

Difference 7: Custom Marshal and Unmarshal Methods

Many production applications customize JSON behavior.

For example:

type UserID int64

func (id UserID) MarshalJSON() ([]byte, error) {
    return json.Marshal(int64(id))
}

A large codebase may contain hundreds of custom marshaling methods.

Changing to v2 therefore requires testing custom serialization behavior rather than only testing ordinary structs.

Go 1.27 provides compatibility options for cases where legacy method semantics need to be preserved.

Search your codebase for:

MarshalJSON
UnmarshalJSON

before migrating.

Difference 8: Error Messages

Go 1.27's release notes specifically note that while encoding/json preserves marshaling and unmarshaling behavior, the exact wording of error messages may differ because the implementation has changed.

This matters if tests contain assertions such as:

if err.Error() != "specific error text" {
    t.Fatal(err)
}

That is fragile.

Prefer checking error behavior at the semantic level where possible.

For example:

if err == nil {
    t.Fatal("expected JSON decoding to fail")
}

rather than depending on the exact error string.

Compare v1 and v2 Explicitly

For migration testing, it is useful to run the same input through both implementations.

For example:

func compareJSON(input []byte) {
    var v1 User
    var v2 User

    err1 := json.Unmarshal(input, &v1)
    err2 := jsonv2.Unmarshal(input, &v2)

    // Compare results and errors.
}

This helps identify inputs where the behavior differs.

Do this with representative production data rather than a handful of synthetic examples.

Test the Wire Format, Not Just Go Values

A common mistake is to write:

reflect.DeepEqual(v1, v2)

and consider the migration safe.

That does not test the complete API contract.

For HTTP APIs, compare:

Request JSON
       |
       v
v1 decoder
       |
       v
Go value

Request JSON
       |
       v
v2 decoder
       |
       v
Go value

and separately compare:

Go value
   |
   +---- v1 Marshal ---> JSON
   |
   +---- v2 Marshal ---> JSON

The serialized JSON is often the actual contract with another service.

Golden Tests Are Useful

If your API has stable JSON responses, maintain golden test files.

For example:

testdata/
    user-response.json
    order-response.json
    invoice-response.json

Then verify that migration does not unintentionally change the wire format.

A simple test pattern is:

func TestUserJSON(t *testing.T) {
    got, err := jsonv2.Marshal(testUser())
    if err != nil {
        t.Fatal(err)
    }

    want := loadGoldenFile("testdata/user-response.json")

    if string(got) != string(want) {
        t.Fatalf("JSON changed")
    }
}

For APIs where JSON member ordering is not contractually relevant, compare decoded structures rather than raw strings.

A Safer Migration Strategy

For a large production application, avoid changing every package at once.

A practical approach is:

Existing Application
       |
       v
Go 1.27
       |
       v
Existing encoding/json
       |
       v
Add compatibility tests
       |
       v
Identify behavior differences
       |
       v
Migrate selected package
       |
       v
Run integration tests
       |
       v
Expand migration

This lets teams separate:

  1. Go toolchain migration

  2. JSON implementation migration

That separation makes failures much easier to diagnose.

Use Compatibility Options When Necessary

Go 1.27 provides options that allow v2 to emulate specific v1 behaviors.

For example, v2 can be configured with compatibility options rather than forcing every application to adopt all new semantics simultaneously.

Conceptually:

result, err := jsonv2.Marshal(
    value,
    compatibilityOption(),
)

This enables an incremental migration.

The exact option should correspond to the behavior that your application actually needs to preserve.

Avoid applying a blanket compatibility configuration without understanding which v1 behavior you are retaining.

Production Migration With jsonsplit

For production services where changing behavior immediately is risky, the Go JSON migration guide describes github.com/go-json-experiment/jsonsplit.

The package can run v1 and v2 serialization or deserialization paths and report differences while continuing to return the v1 result to the application. This can be useful for observing real production traffic before changing the externally visible behavior.

The trade-off is additional processing.

Running both implementations means additional JSON work, so this technique should be evaluated against the workload's latency and CPU requirements.

The general migration model is:

Production Request
       |
       v
Run v1 + v2
       |
       +---- Return v1 result
       |
       +---- Record differences
                 |
                 v
           Fix incompatibilities

This can be especially useful for large APIs where test fixtures do not represent the full range of real client behavior.

What Should You Test?

A JSON migration test suite should include:

Test area

What to verify

Basic structs

Normal marshal/unmarshal

Nil slices

null vs []

Nil maps

null vs {}

Duplicate names

Acceptance or rejection

Invalid UTF-8

Error behavior

Field casing

Matching behavior

Struct tags

Names and options

Custom methods

Marshal/unmarshal behavior

Unknown fields

Existing application expectations

Errors

Semantic behavior

HTTP APIs

Request/response compatibility

Stored JSON

Existing persisted data

Third-party clients

Real integration behavior

Common Mistakes

Assuming Go 1.27 Automatically Migrates Your Application to v2

It does not.

Existing encoding/json usage remains supported and preserves historical behavior.

Testing Only Compilation

Both implementations can compile while producing different JSON.

Ignoring null vs Empty Values

This can break API clients without causing a Go compilation error.

Testing Only Internal Structures

The JSON wire format is often the actual compatibility boundary.

Depending on Exact Error Strings

Error wording can change even when the failure itself remains correct.

Migrating Everything at Once

A large application becomes difficult to troubleshoot when hundreds of endpoints change JSON behavior simultaneously.

Copying Early Experimental Examples

The v2 API evolved before becoming part of Go 1.27. Always use documentation for the released version.

Best Practices

  1. Upgrade to Go 1.27 separately from a JSON v2 migration.

  2. Keep existing encoding/json code where compatibility risk is high.

  3. Inventory custom JSON methods and struct tags.

  4. Add golden tests for important API responses.

  5. Test null versus empty collections explicitly.

  6. Test duplicate JSON names.

  7. Test invalid UTF-8 from external integrations.

  8. Test case-sensitive field matching.

  9. Avoid assertions against exact error-message strings.

  10. Use compatibility options for specific migration blockers.

  11. Compare v1 and v2 against representative production payloads.

  12. Consider production comparison tooling for high-risk APIs.

  13. Migrate package-by-package instead of changing the entire application at once.

  14. Monitor API error rates and response differences after deployment.

Advantages and Disadvantages

Advantages of JSON v2

Migration considerations

Stricter JSON handling

Some existing inputs may be rejected

Better interoperability

null and empty-value behavior can change

Configurable behavior through options

More concepts to understand

New streaming APIs

Existing custom JSON code needs testing

Case-matching controls

Client behavior may depend on old matching

Faster unmarshaling in Go 1.27's implementation

Performance should still be validated on your workload

Go's Go 1.27 release notes report that marshaling performance is broadly at parity with the previous implementation while unmarshaling is significantly faster. These are release-level implementation characteristics; applications should still benchmark their own workloads before drawing conclusions about production performance.

Production Migration Checklist

[ ] Upgrade Go to 1.27
[ ] Keep existing encoding/json behavior initially
[ ] Inventory JSON call sites
[ ] Find MarshalJSON and UnmarshalJSON implementations
[ ] Review JSON struct tags
[ ] Test nil slices
[ ] Test nil maps
[ ] Test duplicate object names
[ ] Test invalid UTF-8
[ ] Test field-name casing
[ ] Test unknown-field behavior
[ ] Compare important API responses
[ ] Compare persisted JSON data
[ ] Test external integrations
[ ] Review error handling
[ ] Add golden tests where appropriate
[ ] Test v2 in staging
[ ] Monitor production behavior
[ ] Migrate incrementally

Summary

Go 1.27 does not force existing applications to move from encoding/json to encoding/json/v2. The original package remains supported, and Go 1.27 uses the new implementation underneath while preserving its historical semantics.

The risk appears when an application explicitly changes to:

import jsonv2 "encoding/json/v2"

The API looks familiar, but several defaults are different.

The most important migration checks are:

Duplicate JSON names
        |
Invalid UTF-8
        |
nil slice/map representation
        |
Case-sensitive field matching
        |
Struct tag behavior
        |
Custom marshaling behavior
        |
Error handling

For small applications, direct migration followed by comprehensive tests may be sufficient.

For large production APIs, a gradual approach is safer: establish compatibility tests, compare real payloads, preserve specific v1 behaviors where necessary, and migrate one part of the application at a time.

The main lesson is simple:

A JSON library migration is a wire-contract migration, not just an import change.