---
title: "Go JSON v2 Migration: What Breaks in Go 1.27"
slug: go-json-v2-migration-what-breaks-in-go-1-27
url: https://listedarticles.com/articles/go-json-v2-migration-what-breaks-in-go-1-27
canonical_url: https://importstatic.com/go/go-json-v2-migration
content_type: guide
language: en
published_at: 2026-10-03T01:20:00.000Z
updated_at: 2026-10-03T12:12:46.777Z
author: "ImportStatic"
authored_by: human
publisher: "ImportStatic"
publisher_url: https://importstatic.com/
topics: ["Programming", "Software Engineering", "Tutorials", "Developer Tools"]
license: all-rights-reserved
word_count: 2246
reading_minutes: 10
citation: "ImportStatic, ImportStatic. \"Go JSON v2 Migration: What Breaks in Go 1.27.\" 3 Oct 2026. https://importstatic.com/go/go-json-v2-migration (all-rights-reserved)"
# The full text follows. The web page shows an extract and sends readers
# to the source above; quote the citation and link the canonical URL.
---

# Go JSON v2 Migration: What Breaks in Go 1.27

> Side-by-side notes on migrating to Go 1.27 encoding/json/v2: which outputs change versus v1 and which options or struct tags restore expected behavior.

# Go JSON v2 in Go 1.27: what breaks when you migrate from encoding/json

Go 1.27 ships encoding/json/v2. We ran v1 and v2 side by side on the same structs: here is every output that changed and the option or tag that fixes it.

Published Oct 3, 2026 9 min read By [ImportStatic Editors](/about)

On this page

  1. What Go 1.27 changed even if you never import v2
  2. Go JSON v2 vs v1, side by side
  3. Tags that give the same JSON under v1 and v2
  4. Migrating one option at a time with DefaultOptionsV1
  5. What we measured: faster unmarshal, slower marshal
  6. Common questions

Go 1.27 put `encoding/json/v2` in the standard library, and the part most people will miss is that you are already running it. Since Go 1.27, the old `encoding/json` package is implemented on top of the v2 engine with a set of compatibility options switched on. Your output should not change, but your error strings can, and the moment you change an import to `encoding/json/v2` the defaults flip: nil slices become `[]`, field names match case-sensitively, duplicate keys are rejected and `time.Duration` stops marshaling at all.

Go 1.27.0 was released on 2026-08-19 and the current point release, as of 2026-10-03, is Go 1.27.1. Every program and output below was run on go1.27.1 darwin/arm64. The benchmark section also uses `GOEXPERIMENT=nojsonv2` to compare against the old implementation.

## What Go 1.27 changed even if you never import v2

The [Go 1.27 release notes](https://go.dev/doc/go1.27) describe two new packages and one swapped engine:

  * `encoding/json/v2` is the new semantic API: `Marshal`, `Unmarshal`, `MarshalWrite`, `UnmarshalRead`, `MarshalEncode` and `UnmarshalDecode`, each taking variadic `Options`.
  * `encoding/json/jsontext` is the lower-level syntactic layer, with an `Encoder` and `Decoder` that work on tokens and raw values.

The notes then say that `encoding/json` "is now backed by the v2 implementation. Marshaling and unmarshaling behavior is preserved, but the exact text of error messages may differ." The v1 API stays supported and nobody is required to migrate. If something does break after the upgrade, building with `GOEXPERIMENT=nojsonv2` restores the original code, but the release notes say that opt-out "is expected to be removed in a future release."

So the first migration step costs nothing: upgrade to 1.27 and run your tests. The one category to look for is tests that compare `err.Error()` against a fixed string. Those can fail without any behaviour change, and the fix is to assert on error types such as `*json.SyntaxError` or `*json.UnmarshalTypeError` instead.

## Go JSON v2 vs v1, side by side

The package documentation for `encoding/json` has a "Migrating to v2" section listing every behaviour difference and the option that controls it. Reading a list is one thing; seeing your own structs change is another. Here is the struct we used:

GoCopy
    
    
    type User struct {
    	Name    string   `json:"name"`
    	Tags    []string `json:"tags"`
    	Admin   bool     `json:"admin,omitempty"`
    	Retries int      `json:"retries,omitempty"`
    }

The program marshals and unmarshals the same values with `jsonv1 "encoding/json"` and `"encoding/json/v2"` and prints both. This is the real output:

TerminalCopy
    
    
    == nil slice and omitempty on bool/int ==
    v1   {"name":"ana","tags":null}
    v2   {"name":"ana","tags":[],"admin":false,"retries":0}
    == case-insensitive field names ==
    v1   "bob" err=<nil>
    v2   "" err=<nil>
    == duplicate names ==
    v1   "mallory" err=<nil>
    v2   "alice" err=jsontext: duplicate object member name "name"
    == invalid UTF-8 ==
    v1   "caf�" err=<nil>
    v2   "" err=jsontext: invalid UTF-8 within "/name" after offset 12
    == time.Duration ==
    v1   {"name":"backup","timeout":90000000000}
    v2   error: json: unable to marshal from Go time.Duration within "/timeout": no default representation
    v2+  {"name":"backup","timeout":90000000000}
    == HTML escaping ==
    v1   {"q":"<a&b>"}
    v2   {"q":"<a&b>"}
    v2+  {"q":"<a&b>"}

Going through them in order of how likely they are to hurt.

**Nil slices and maps.** v1 writes `null` for a nil slice or map; v2 writes `[]` and `{}`. That is friendlier to JavaScript clients and a silent contract change for anyone who checks `=== null`. `json.FormatNilSliceAsNull(true)` and `json.FormatNilMapAsNull(true)` bring the old output back.

**omitempty means something else.** The first line shows `"admin":false,"retries":0` reappearing. In v1, `omitempty` drops false, 0, nil pointers and nil interfaces. In v2 it drops a field only if it would encode as JSON `null`, `""`, `{}` or `[]`. The documentation says the two agree for strings, slices, maps and arrays, and that existing uses on a bool, number, pointer or interface "should migrate to specifying `omitzero` instead", which works the same in both versions.

**Case-insensitive field matching is gone.** v1 happily put `{"NAME":"bob"}` into the `Name` field. v2 matches names exactly, so the field stays empty and, notice, no error is returned. If your clients send inconsistent casing, you will find out from missing data, not from logs. `json.MatchCaseInsensitiveNames(true)` restores the loose match for a whole call; the `case:ignore` tag option does it per field.

**Duplicate keys are an error.** v1 let the last `"name"` win, which is how `{"name":"alice","name":"mallory"}` became mallory. The v2 documentation's security section explains why this matters when two services parse the same request differently. v2 rejects it unless you pass `jsontext.AllowDuplicateNames(true)`. One detail from our run: after the error, the destination already held `"alice"`. Treat the struct as garbage once `Unmarshal` returns an error.

**Invalid UTF-8 is an error.** v1 replaced the bad byte with U+FFFD; v2 refuses, unless `jsontext.AllowInvalidUTF8(true)` is set. If you ingest data from old Latin-1 systems, this is the one that will show up in production.

**time.Duration has no representation.** In v2, a `time.Duration` "has no default representation and results in a SemanticError". That is the `v2 error` line. The `v2+` line passes `jsonv1.FormatDurationAsNano(true)`, an option that lives in the v1 package, and gets the old nanosecond integer back.

**HTML escaping is off.** v2 uses minimal escaping, so `<a&b>` goes out as is. If you embed JSON in HTML `<script>` blocks, keep `jsontext.EscapeForHTML(true)`.

The documentation lists a few more that our structs did not hit: Go arrays must now be unmarshaled from a JSON array of exactly the same length, byte arrays (not slices) become Base64 strings, maps are no longer sorted when marshaled unless you pass `json.Deterministic(true)`, the `string` tag option only applies to values that encode as numbers, unmarshaling `null` always zeroes the target, and malformed struct tags are reported as errors at runtime instead of being ignored.

## Tags that give the same JSON under v1 and v2

You do not have to pick a side per call site. Most differences can be pinned on the type, so the same struct encodes identically whichever package touches it. This version of the struct passes a test that marshals with both and compares bytes:

GoCopy
    
    
    // Portable keeps the same JSON under v1 and v2.
    type Portable struct {
    	Name    string   `json:"name,case:ignore"`
    	Tags    []string `json:"tags,omitempty"`
    	Admin   bool     `json:"admin,omitzero"`
    	Retries int      `json:"retries,omitzero"`
    }
    
    func TestPortableSameBytes(t *testing.T) {
    	for _, p := range []Portable{{Name: "ana"}, {Name: "bo", Tags: []string{"x"}, Admin: true, Retries: 3}} {
    		b1, err := jsonv1.Marshal(p)
    		if err != nil {
    			t.Fatal(err)
    		}
    		b2, err := json.Marshal(p)
    		if err != nil {
    			t.Fatal(err)
    		}
    		if string(b1) != string(b2) {
    			t.Errorf("v1 %s != v2 %s", b1, b2)
    		}
    		t.Logf("%s", b2)
    	}
    }

TerminalCopy
    
    
    === RUN   TestPortableSameBytes
        main_test.go:31: {"name":"ana"}
        main_test.go:31: {"name":"bo","tags":["x"],"admin":true,"retries":3}
    --- PASS: TestPortableSameBytes (0.00s)

`omitempty` on the slice makes nil and empty both disappear, which sidesteps the `null` versus `[]` question. `omitzero` replaces `omitempty` on the bool and int. `case:ignore` keeps accepting `"NAME"` under v2, and a second test confirmed it decodes into `Name`.

While you are in there, v2 has an option v1 never had. `json.RejectUnknownMembers(true)` turns a typo or an unexpected field into an error that wraps `json.ErrUnknownName`:

TerminalCopy
    
    
    json: cannot unmarshal JSON string into Go main.Portable: unknown object member name "role"

That is worth having on any request body that reaches an authorization check.

If you used v2 under `GOEXPERIMENT=jsonv2` on Go 1.25 or 1.26, check your tags again. The release notes list changes made before the package became final: the `format` and `unknown` tag options were removed, the `DiscardUnknownMembers` option and `SkipFunc` were removed, and the `inline` tag option was renamed `embed`. Blog posts written during the experiment still show `format:` tags that will not work on 1.27.

## Migrating one option at a time with DefaultOptionsV1

For a large codebase, the safe path is the one the official [migration guide](https://go.dev/doc/jsonv2-migration) calls option-by-option. Change call sites to the v2 functions but pass `jsonv1.DefaultOptionsV1()`, which the guide calls "a trivial and safe change". Then switch individual behaviours to v2 by adding options after it, since later options override earlier ones:

GoCopy
    
    
    b, err = json.Marshal(u, jsonv1.DefaultOptionsV1())
    show("v2v1", b, err)
    b, err = json.Marshal(u, jsonv1.DefaultOptionsV1(), json.FormatNilSliceAsNull(false))
    show("mix", b, err)

TerminalCopy
    
    
    v2v1 {"name":"ana","tags":null}
    mix  {"name":"ana","tags":[]}

The first call is byte-for-byte v1. The second is v1 except for nil slices. Each option you flip is a small, reviewable change, and when the list of overrides covers everything you can drop `DefaultOptionsV1()` entirely.

For servers where you cannot predict every payload from tests, the guide points to [`github.com/go-json-experiment/jsonsplit`](https://pkg.go.dev/github.com/go-json-experiment/jsonsplit). It has call modes such as `CallBothButReturnV1`, which runs both implementations, returns the v1 result and reports any difference, so production traffic tells you which options you need before you switch to `CallBothButReturnV2` or `OnlyCallV2`. The guide warns that running both "will approximately double the cost of marshaling". Use it for the migration window, not permanently.

## What we measured: faster unmarshal, slower marshal

The release notes say "marshal performance is broadly at parity with the previous implementation, while unmarshal performance is significantly faster." We checked that on one payload: a 500-element array of a struct with an int64, a string, a float, a three-element string slice, a two-entry `map[string]string` and a nested struct, about 79 KB of JSON. The benchmark calls plain `encoding/json`, run twelve times on the default 1.27.1 build and six times with `GOEXPERIMENT=nojsonv2`, on an Apple M4 Pro:

encoding/json on Go 1.27.1 | Unmarshal | Allocs | Marshal | Allocs  
---|---|---|---|---  
v2 engine (default) | ~695 µs | 5,011 | ~336 µs | 2,003  
old engine (`nojsonv2`) | ~920 µs | 7,018 | ~241 µs | 2,502  
  
Times are rounded medians; the unmarshal runs were noisy (537 to 878 µs on the new engine), while the allocation counts were identical on every run. Unmarshal came out about 25% faster with 29% fewer allocations, which matches the release notes. Marshal took about 39% longer on this payload, which does not match "broadly at parity". One payload on one machine is not a verdict, but it is enough to say: if marshaling is on your hot path, benchmark it on 1.27 with and without `nojsonv2` before you roll out.

If you are upgrading for the other Go 1.27 changes as well, the new [`synctest.Sleep` and in-memory test server](/go/go-synctest-testing-concurrent-code) are worth a look in the same pass.

## Common questions

### Do I have to migrate to encoding/json/v2 in Go 1.27?

No. The Go team says the v1 encoding/json API will continue to be supported and users are not required to migrate. In Go 1.27, though, v1 is implemented on top of the v2 engine, so error message text can change even if you never touch your imports.

### How do I turn off the new JSON implementation in Go 1.27?

Build with GOEXPERIMENT=nojsonv2. That restores the original v1 implementation behind encoding/json. The release notes say this opt-out is expected to be removed in a future release, so treat it as a stopgap and file an issue for whatever made you need it.

### Why does json/v2 fail to marshal time.Duration?

In v2 a time.Duration has no default representation, so Marshal returns a SemanticError. Pass the encoding/json option FormatDurationAsNano(true) to get the v1 behaviour of encoding nanoseconds as a JSON number.

### Is omitempty the same in json v2?

Only for strings, slices, maps and arrays. v2 omits a field when it would encode as JSON null, an empty string, an empty object or an empty array, so false and 0 are no longer omitted. Use omitzero on bools, numbers, pointers and interfaces; it behaves the same in v1 and v2.

### Is encoding/json/v2 faster than v1?

The Go 1.27 release notes say unmarshal is significantly faster and marshal is broadly at parity. In our benchmark on Go 1.27.1, unmarshal was about 25% faster with 29% fewer allocations, while marshal took about 39% longer on that payload, so measure your own hot paths.

Sources checked for this article (6)

  1. [Go 1.27 Release Notes](https://go.dev/doc/go1.27) - New v2 and jsontext packages, v1 now backed by v2, GOEXPERIMENT=nojsonv2, performance statement, tag options removed or renamed during the experiment
  2. [encoding/json package documentation, Go 1.27.1](https://pkg.go.dev/encoding/json) - Migrating to v2 section: full list of behaviour differences and the option controlling each, DefaultOptionsV1
  3. [encoding/json/v2 package documentation, Go 1.27.1](https://pkg.go.dev/encoding/json/v2) - Tag options (omitzero, omitempty, string, case, embed), RejectUnknownMembers, Duration has no default representation, security considerations
  4. [encoding/json/v2 Migration Guide](https://go.dev/doc/jsonv2-migration) - All-at-once, option-by-option and jsonsplit migration approaches
  5. [jsonsplit package documentation, version of 14 August 2026](https://pkg.go.dev/github.com/go-json-experiment/jsonsplit) - Call modes for running v1 and v2 side by side
  6. [Go release history](https://go.dev/doc/devel/release) - Go 1.27.0 released 2026-08-19, Go 1.27.1 released 2026-09-01

  * [encoding/json/v2](/tag/encoding-json-v2)
  * [Go 1.27](/tag/go-1-27)
  * [json](/tag/json)
  * [migration](/tag/migration)
  * [serialization](/tag/serialization)
  * [standard library](/tag/standard-library)

ImportStatic articles are researched and drafted with AI assistance, checked against primary sources, and every code sample is compiled and run before publishing. Found a mistake? [Here is how corrections work.](/about#corrections)
