Go 1.27 encoding/json/v2 Migration Guide: What Actually Breaks (and How to Fix It)
Go 1.27 ships json/v2 as the default JSON engine. Learn exactly what breaks — nil slice behavior, case-sensitive matching, UTF-8 strictness, duplicate keys — and get migration code examples for every change.
Have broken JSON right now? Fix it free in under 1 second — no signup.
Fix My JSON →Go 1.27 shipped on August 2, 2026, and it brought one of the most significant standard-library changes in Go's history: encoding/json is now backed by the v2 implementation. Your existing encoding/json imports keep working — but the engine underneath changed, and several long-standing behaviors are now different.
This guide covers every behavior change that can break production code, with before/after examples and migration paths for each.
What Is encoding/json/v2?
The v2 JSON engine has been under development since 2023 as an experimental package (golang.org/x/exp/jsonv2). In Go 1.27 it graduates from experimental to standard: the existing encoding/json package is backed by v2 under the hood, while keeping the familiar json.Marshal / json.Unmarshal API surface intact.
You do not need to change your import paths. Code that imports "encoding/json" continues to compile and run. The differences are in runtime behavior — stricter defaults and changed semantics for edge cases.
If you hit a compatibility issue, you can restore v1 behavior for your entire build with:
GOEXPERIMENT=nojsonv2 go build ./...
But that is a temporary escape hatch, not a long-term solution. This guide helps you fix the actual issues so your code runs correctly under v2.
Breaking Change 1: Nil Slices and Maps Now Marshal as [] and {}
This is the change most likely to silently break your API consumers.
v1 behavior (before Go 1.27):type Response struct {
Items []string `json:"items"`
Meta map[string]string `json:"meta"`
}
r := Response{} // Items = nil, Meta = nil
b, _ := json.Marshal(r)
fmt.Println(string(b))
// Output: {"items":null,"meta":null}
v2 behavior (Go 1.27+):
r := Response{} // Items = nil, Meta = nil
b, _ := json.Marshal(r)
fmt.Println(string(b))
// Output: {"items":[],"meta":{}}
Why This Breaks Things
If any downstream consumer treats null and [] differently — and many do, especially in JavaScript, Swift, or Kotlin clients — your API silently changes shape. null often means "field not present" or "unknown", while [] means "empty list". These are semantically different in most API designs.
How to Fix It
Option A: Useomitempty to omit nil fields entirely (preferred for new APIs):
type Response struct {
Items []string `json:"items,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
// nil Items and Meta are omitted from output entirely
Option B: Restore v1 null behavior with the compatibility options:
import "encoding/json/v2"
b, _ := jsonv2.Marshal(r, jsonv2.FormatNilSliceAsNull(true), jsonv2.FormatNilMapAsNull(true))
Option C: Always initialize your slices and maps (best practice regardless):
r := Response{
Items: []string{}, // empty but not nil
Meta: map[string]string{},
}
omitempty Also Changed
The omitempty tag option was redefined in v2. In v1, omitempty skips a field if it is the zero value for its type. In v2, omitempty skips a field if it would encode as an "empty" JSON value — which means null, "", [], or {}.
Practically, the behavior is the same for slices, maps, and strings. The difference surfaces for structs: a non-nil struct that encodes to {} is now omitted under v2's omitempty, whereas v1 would have included it.
Breaking Change 2: Case-Sensitive Field Matching
In v1, unmarshaling a JSON object into a Go struct used case-insensitive name matching. A JSON field "Name" would happily populate a Go struct field tagged json:"name". This was convenient but produced surprising results with ambiguous cases.
v2 now uses exact, case-sensitive matching by default.
Code that breaks silently:type User struct {
Name string `json:"name"`
Email string `json:"email"`
}
data := `{"Name":"Alice","Email":"[email protected]"}`
var u User
json.Unmarshal([]byte(data), &u)
// v1: u = {Name:"Alice", Email:"[email protected]"}
// v2: u = {Name:"", Email:""} — fields not matched, silently zero
The Go JSON encoder typically outputs lowercase field names per your struct tags, so round-tripping your own structs is fine. The breakage happens when consuming external APIs that don't follow Go conventions, or legacy data with inconsistent casing.
How to Fix It
Option A: Fix the JSON field names — if you control the source, emit field names that exactly match your struct tags. Option B: Normalize field names before unmarshaling — write a preprocessing step that lowercases JSON keys. Option C: Restore case-insensitive matching with the compatibility option:import "encoding/json/v2"
var u User
jsonv2.Unmarshal(data, &u, jsonv2.MatchCaseInsensitiveNames(true))
Option D: Use a custom UnmarshalJSON method for structs where you need flexibility.
Finding Affected Code
Run this to find unmarshal calls consuming external data (API responses, files) where casing may be inconsistent:
grep -rn "json.Unmarshal\|json.NewDecoder" ./... | grep -v "_test.go"
For each call site, verify that the JSON keys coming in exactly match your struct tag values.
Breaking Change 3: Invalid UTF-8 Now Returns an Error
v1 silently replaced invalid UTF-8 bytes with the Unicode replacement character (U+FFFD). v2 returns an error instead.
v1 behavior:data := []byte(`{"name": "caf\xe9"}`) // \xe9 is invalid UTF-8 in isolation
var m map[string]string
json.Unmarshal(data, &m)
// v1: m["name"] = "caf\ufffd" — replacement char, no error
v2 behavior:
err := json.Unmarshal(data, &m)
// v2: err = "invalid UTF-8 sequence"
Why This Matters
This catches data corruption early. But if you're consuming JSON from external systems that produce bad UTF-8 (some legacy databases, certain HTTP APIs), your error handling needs updating.
How to Fix It
Option A: Sanitize input before parsing — recommended for production:import "unicode/utf8"
func sanitizeUTF8(b []byte) []byte {
if utf8.Valid(b) {
return b
}
// Replace invalid bytes
return []byte(strings.ToValidUTF8(string(b), "\ufffd"))
}
data = sanitizeUTF8(data)
json.Unmarshal(data, &m)
Option B: Restore v1 permissive behavior:
jsonv2.Unmarshal(data, &m, jsonv2.AllowInvalidUTF8(true))
Breaking Change 4: Duplicate JSON Keys Now Return an Error
v1 silently took the last value when a JSON object had duplicate key names. v2 rejects them with an error.
Input that previously worked:{"id": 1, "id": 2, "name": "Alice"}
v1: id was silently set to 2 (last value wins).
v2: Returns an error: duplicate name "id".
How to Fix It
Option A: Fix the source — duplicate keys in JSON are illegal per RFC 8259. If you control the producer, fix it. Option B: Pre-process to deduplicate — for external data you cannot control:func dedupJSON(data []byte) ([]byte, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(data, &m); err != nil {
// If unmarshal itself fails on duplicate, use a streaming approach
return dedupeStream(data)
}
return json.Marshal(m)
}
Option C: Restore last-value-wins behavior:
jsonv2.Unmarshal(data, &m, jsonv2.AllowDuplicateNames(true))
Breaking Change 5: Error Message Text Changed
If you have tests that strings.Contains(err.Error(), "some specific text"), they may break because v2 produces different error messages than v1.
json: cannot unmarshal number into Go struct field User.name of type string
v2 error: cannot unmarshal JSON number into value of type string
How to Fix It
Switch to type-based error checking instead of string matching:
var syntaxErr *json.SyntaxError
var unmarshalErr *json.UnmarshalTypeError
switch {
case errors.As(err, &syntaxErr):
fmt.Printf("syntax error at offset %d\n", syntaxErr.Offset)
case errors.As(err, &unmarshalErr):
fmt.Printf("type mismatch for field %s\n", unmarshalErr.Field)
}
Breaking Change 6: Streaming Interfaces Changed (for v2 Direct Usage)
If you opt into the encoding/json/v2 package directly (not via the compatibility shim), the streaming API is new. v2 introduces MarshalerTo and UnmarshalerFrom interfaces that write/read directly to/from an io.Writer/io.Reader without intermediate buffering.
// New v2 streaming interface
type MarshalerTo interface {
MarshalJSONTo(enc *jsonv2.Encoder) error
}
type UnmarshalerFrom interface {
UnmarshalJSONFrom(dec *jsonv2.Decoder) error
}
The old json.Marshaler and json.Unmarshaler interfaces still work through the compatibility layer. Only switch to the new interfaces if you need the streaming performance benefits.
Migration Checklist
Before upgrading a production service to Go 1.27:
[ ] Run tests under GOEXPERIMENT=jsonv2 go test ./...
[ ] Search for nil slice/map marshal calls that feed external APIs
[ ] Search for json.Unmarshal calls consuming external JSON with inconsistent casing
[ ] Review error message assertions in tests
[ ] Audit any JSON from legacy sources for invalid UTF-8 or duplicate keys
[ ] Check omitempty usage on struct fields that should remain in output when empty
Performance: The Good News
v2 is significantly faster than v1, especially for unmarshaling. Early benchmarks show 2–3× faster unmarshaling for complex nested structs. The stricter behavior comes with a performance win — the v2 parser is built around a more efficient internal representation.
If you're using encoding/json in a hot path today, upgrading to Go 1.27 and running under v2 is likely a free performance improvement once you've fixed the behavioral differences.
If Your JSON Is Broken, Fix It First
Before you can migrate cleanly, your JSON input needs to be valid. If your services consume JSON from external APIs, LLMs, or legacy data stores, the invalid JSON may have been silently tolerated by v1. v2 will surface those errors.
Use AI JSONMedic to repair and validate malformed JSON before processing it with Go 1.27's stricter engine. Paste the broken output, get valid JSON back, then wire up proper sanitization in your production pipeline.
Summary
| Change | v1 behavior | v2 behavior | Fix |
|---|---|---|---|
| Nil slice marshal | null | [] | Init slice, use omitempty, or FormatNilSliceAsNull |
| Nil map marshal | null | {} | Init map, use omitempty, or FormatNilMapAsNull |
| Field name matching | Case-insensitive | Case-sensitive (exact) | Fix keys or use MatchCaseInsensitiveNames |
| Invalid UTF-8 | Silently replaced | Returns error | Sanitize input or AllowInvalidUTF8 |
| Duplicate JSON keys | Last value wins | Returns error | Fix source or AllowDuplicateNames |
| Error messages | Old text | New text | Use errors.As for type-based checks |
Go 1.27's json/v2 is a net improvement — stricter, faster, and more correct. The migration work is real but bounded. Run your test suite under the new engine, fix the behavioral differences with the checklist above, and you'll end up with more reliable JSON handling across your entire Go codebase.
Found broken JSON while migrating? Repair it instantly with AI JSONMedic. Need to validate your Go structs produce correct JSON Schema? Try the JSON Schema Generator.
Still dealing with broken JSON?
Paste it in and get it fixed in under 1 second — free, no signup, no install. Works with ChatGPT, Claude, n8n, and any AI output.
Fix My JSON Free →Related Articles