Software

Does Go 1.27's json v2 break your LLM parsing? We tested

By · Sat Oct 17 2026 · 10 min read · 0 views

View as a Web Story

Software#llm#go#golang#go 1.27#encoding/json

Go encoding/json v1 and v2 results for eleven LLM-style JSON payloads on Go 1.26.9 and 1.27.2

Does Go 1.27's json v2 break your LLM parsing? We tested

Does Go 1.27's JSON change break existing code?

For the payloads we tried, it does not. Go 1.27 now runs the classic encoding/json API on the v2 implementation, but the release notes say Marshal and Unmarshal behavior is preserved. They also warn that error message text may differ. See the Go 1.27 release notes for the full wording.

Some blog posts claim the opposite, that the old API now rejects bad UTF-8 and duplicate keys. We tested that claim. It did not hold for the v1 API.

encoding/json is the Go standard library package that converts between JSON text and Go values. encoding/json/v2 is a new package in Go 1.27 with stricter defaults and more options. jsontext is a companion package for working with JSON tokens, also new in 1.27. The Go team explained the design in its JSON v2 experiment post, and the API is on pkg.go.dev.

The two sit side by side. Your existing imports keep working. The strict behavior lives in the v2 package, which you opt into by importing it.

What did we test, and how?

We wrote one Go program with 11 payloads, because we wanted to see the behavior ourselves instead of trusting a summary. We ran it on three setups: Go 1.26.9, Go 1.27.2, and Go 1.27.2 with GOEXPERIMENT=nojsonv2. The struct had a string, a slice, a float and a pointer field. For each payload we called json.Unmarshal and recorded OK or the error text.

The payloads imitate what language models actually return. Models repeat keys, wrap output in markdown fences, leave trailing commas and sometimes emit broken Unicode. Each case is a small string, so you can paste it into your own test.

Payload v1 on Go 1.26.9 v1 on Go 1.27.2 v2 package on Go 1.27.2
Clean object OK OK OK
Invalid UTF-8 byte in a string OK OK error
Duplicate key ("name" twice) OK, last value wins OK, last value wins error
Lone surrogate \ud800 OK OK error
Key in the wrong case (NAME) OK OK OK
Unknown field OK OK OK
null for a slice OK OK OK
Trailing comma error error error
Markdown fence around the JSON error error error
Number where a string is expected error error not run
Trailing text after the object error error not run

Source: our test program, Go 1.26.9 and 1.27.2, October 16, 2026. The last two rows were run on v1 only.

Results for eleven JSON payloads: v1 on Go 1.26.9, v1 on Go 1.27.2, and the v2 package on Go 1.27.2

The first two columns match on every row. That includes the error text for the four failing payloads we compared. Setting GOEXPERIMENT=nojsonv2 changed nothing in our run, which is what you would expect if the behavior is preserved.

What does the v2 package reject?

Therefore the strictness gap is real, but it lives in one place. It rejects three things that v1 quietly accepts: invalid UTF-8, duplicate object keys and invalid surrogate pairs. The v2 errors name the exact location. That is useful when you debug a model that produces bad output.

Advertisement

These are the messages we got from Go 1.27.2:

jsontext: invalid UTF-8 within "/name" after offset 12
jsontext: duplicate object member name "name"
jsontext: invalid surrogate pair `\ud800","tag` in string within "/name" after offset 9

Compare that with v1 for a trailing comma: invalid character '}' looking for beginning of object key string. The v2 message for the same payload reads jsontext: invalid character ',' at start of value after offset 31. If your tests assert on error text, expect to update them when you move to v2.

Duplicate keys deserve special attention, and researchers have documented why. A 2024 AsiaCCS study, Cross-Language Differential Testing of JSON Parsers, compared 22 parsers in five languages and found confusion over object keys. Bishop Fox surveyed 49 parsers and found risky interoperability behavior in each language it covered. Consider a tool call where the model returns {"path":"/safe","path":"/etc"}. The v1 API silently keeps the last value. Another parser in your stack, in a different language, may keep the first. If a validator and an executor disagree, the call that was checked is not the call that runs.

We did not build an exploit, and we do not claim your stack has this flaw. The point is simpler. A strict parser turns an ambiguous payload into an error you can see.

How do the two APIs differ when writing JSON?

One difference matters for API clients: a nil slice. The v1 API writes null. The v2 package writes []. We saw both in the same run.

Value json.Marshal (v1) jsonv2.Marshal
R{} with a nil Tags slice "tags":null "tags":[]
R{Tags: []string{}} "tags":[] "tags":[]

Source: our test program on Go 1.27.2.

One blog we read said nil slices now serialize differently in Go 1.27 generally. In our run, the v1 json.Marshal still wrote null on both toolchains. Only the v2 package wrote []. Check which API you call before you change a frontend.

This matters if a mobile app treats null and an empty list differently. Switching marshaling to v2 is a contract change, even if it is a good one. Do it deliberately, behind a test.

How much faster is Go 1.27's Unmarshal?

About 2.3 times faster for a small object, in our benchmark. The release notes say Unmarshal is significantly faster and Marshal is roughly at parity. Our numbers agree.

We ran go test -bench three times on each toolchain with a 200-byte string field, four tags and a float. The table lists the median of three runs.

Benchmark Go 1.26.9 Go 1.27.2 Change
json.Unmarshal 1,774 ns/op 763 ns/op 2.3x faster
json.Marshal 441 ns/op 479 ns/op about 9% slower

Source: our benchmark, three runs per toolchain, Apple Silicon, October 16, 2026. A synthetic object, so your payloads will differ.

Median json.Unmarshal and json.Marshal time per operation on Go 1.26.9 and Go 1.27.2

In a separate program on Go 1.27.2, the v2 package took 530 ns for the same Unmarshal against 716 ns for v1. Those two numbers came from one program with a different benchmark harness, so compare them with each other, not with the table.

For a service that parses model responses, the saving is small in absolute terms. A model call takes hundreds of milliseconds. The speedup matters more on services that decode thousands of small webhook bodies per second.

How should you parse LLM JSON in Go 1.27?

What is the safest default for model output? For example, a tool-call argument or a structured answer should fail loudly when it is malformed. Strip the markdown fence, parse with the v2 package, and fail loudly on bad input. Both v1 and v2 reject a fenced block, so the fence must go first. This helper handles the common case.

package main

import (
	"fmt"
	"regexp"

	jsonv2 "encoding/json/v2"
)

var fence = regexp.MustCompile("(?s)^\\s*```(?:json)?\\s*(.*?)\\s*```\\s*$")

type Result struct {
	Name  string   `json:"name"`
	Tags  []string `json:"tags"`
	Score float64  `json:"score"`
}

func ParseModelJSON(raw string) (Result, error) {
	if m := fence.FindStringSubmatch(raw); m != nil {
		raw = m[1]
	}
	var r Result
	if err := jsonv2.Unmarshal([]byte(raw), &r); err != nil {
		return r, fmt.Errorf("model returned invalid JSON: %w", err)
	}
	return r, nil
}

This needs Go 1.27, because encoding/json/v2 does not exist in 1.26. We ran this function on Go 1.27.2 with five inputs. It accepted a fenced object and a clean object, and it rejected a duplicate key, invalid UTF-8 and a trailing comma with an offset in each error message.

However, strictness alone does not fix a bad answer. Add a retry loop on top. On a parse error, send the error text back to the model and ask for corrected JSON. The v2 messages carry an offset, which gives the model something concrete to fix.

How can you reproduce our test?

Copy the program below into a module with go 1.27 in go.mod, then run it with Go 1.26.9 and Go 1.27.2. Remove the jsonv2 lines on 1.26, because that package does not exist there. The core is a table of payload strings and a loop that prints OK or the error text.

type R struct {
	Name  string   `json:"name"`
	Tags  []string `json:"tags"`
	Score float64  `json:"score"`
}

cases := []struct{ name, in string }{
	{"clean", `{"name":"a","tags":["x"],"score":1.5}`},
	{"invalid UTF-8", "{\"name\":\"caf\xe9\",\"tags\":[],\"score\":1}"},
	{"duplicate key", `{"name":"a","name":"b","tags":[],"score":1}`},
	{"lone surrogate", `{"name":"\ud800","tags":[],"score":1}`},
	{"trailing comma", `{"name":"a","tags":[],"score":1,}`},
}
for _, c := range cases {
	var a, b R
	e1 := json.Unmarshal([]byte(c.in), &a)
	e2 := jsonv2.Unmarshal([]byte(c.in), &b)
	fmt.Printf("%-16s v1:%v  v2:%v\n", c.name, e1 == nil, e2 == nil)
}

Add your own payloads to the table. The best ones come from your logs: take the last twenty model responses that failed validation and paste them in. Real failures beat invented ones.

For speed, use the standard testing package. Write BenchmarkUnmarshal and BenchmarkMarshal functions that loop b.N times, then run go test -bench . -count 3 -run xxx. Compare medians, because single runs on a laptop vary by more than 30%. Our Go 1.26.9 Unmarshal runs ranged from 1,729 to 2,546 ns, which is why we report the median.

Our machine was an Apple Silicon Mac under normal desktop load. Your server hardware will give different absolute numbers. The ratio between toolchains is the part to trust, and you should measure it on your own payloads before you quote it to a team.

What else changed in the 1.27 JSON packages?

Several options and tags were renamed or removed during the experiment. The release notes list them, and the details matter if you used the experimental v2 package before Go 1.27.

  • The inline tag option is now embed.
  • The format, unknown tag options and the DiscardUnknownMembers marshal option were removed.
  • The SkipFunc sentinel error was removed.
  • The string tag option and MatchCaseInsensitiveNames behavior changed.

The notes do not say whether every item applies to encoding/json itself or only to v2. We did not use these options in our test. If you tried v2 under GOEXPERIMENT=jsonv2 in an earlier release, run your tests first.

Also note the unrelated changes that ride along. compress/flate output bytes changed, so gzip, zlib, zip and PNG outputs can differ byte for byte. Golden-file tests that compare compressed bytes will fail. That is a more likely source of upgrade pain than JSON.

The other change worth a look in the same release is HTTP connection reuse, which we measured in Go 1.27 now drains HTTP bodies on Close: what we measured. If a build fails first, the fix is in Go 1.27 refuses to build with removed GODEBUG settings.

What does this mean for services that call LLM APIs?

For most services, nothing breaks and the parser gets a little faster. A typical chat integration parses one JSON response per model call. Our benchmark saved about one microsecond per small object, which is invisible next to a model call that takes hundreds of milliseconds.

The real decision is about trust, not speed. Do you want ambiguous model output to pass silently, or fail with an offset? Use the strict package where a wrong value is expensive: tool-call arguments, payment fields and file paths. Keep the classic API where leniency is fine, such as logging and analytics.

Should you upgrade now or wait?

Upgrade now if JSON parsing is your only worry. The classic API behaved identically in our tests, and Unmarshal got faster. Wait only if you compare compressed output byte for byte.

Here is our order of operations:

  1. Upgrade the toolchain and run your suite. Expect no JSON failures.
  2. Fix tests that assert on error text.
  3. Add the v2 package for model output and webhook bodies where ambiguity is dangerous.
  4. Delay switching marshaling to v2 until clients accept [] for empty lists.

If you want a safety net, run GOEXPERIMENT=nojsonv2 in CI for one release. The release notes say that opt-out will be removed in a future version, so treat it as a short bridge.

Advertisement

FAQ

Does Go 1.27 encoding/json reject duplicate keys?

Not through the classic API. In our test, `json.Unmarshal` on Go 1.27.2 accepted a duplicate key and kept the last value, exactly like Go 1.26.9. The new `encoding/json/v2` package rejects it with `duplicate object member name`.

Is encoding/json v1 behavior the same in Go 1.27?

For our 11 payloads, yes. The release notes say behavior is preserved but error text may differ. We found identical results and identical error text on the payloads we compared, so test your own error-string assertions.

Why is json.Unmarshal faster in Go 1.27?

Go 1.27 backs `encoding/json` with the v2 implementation. The release notes call Unmarshal significantly faster. In our benchmark, the median time fell from 1,774 ns to 763 ns for a small object, about 2.3 times faster.

How do I parse JSON from an LLM in Go?

Remove any markdown code fence, then call `Unmarshal` from `encoding/json/v2` so invalid UTF-8 and duplicate keys fail loudly. On an error, send the message back to the model and retry once or twice.

Does the v2 package marshal nil slices as null?

No. In our test, `jsonv2.Marshal` wrote a nil slice as `[]`, while `json.Marshal` wrote `null`. Switching to v2 for output is therefore a contract change for any client that treats `null` and an empty list differently.

Comments

Loading…

Sign in to join the conversation.

Related posts