ErrLookup › Background articles › "failed to unmarshal" / json.Unmarshal errors: why parsing a response into a Go struct fails and how to fix it
"failed to unmarshal" / json.Unmarshal errors: why parsing a response into a Go struct fails and how to fix it
Errors like "failed to unmarshal bgp state", "unmarshalling response object", or "failed to parse ... args: unexpected end of JSON input" all come from Go's json.Unmarshal failing to decode bytes into an expected struct. Developers hit them when an HTTP API returns HTML instead of JSON, a response is truncated or empty, a struct doesn't match the server's current schema, or a config/state file was hand-edited or corrupted. This article explains the mechanics behind the json-unmarshal-failed error family and the fixes that apply across libraries.
Distilled from 119 documented records across 23 repositories.
Background
This family sits at the boundary where raw bytes become typed data. A Go program has already obtained a payload — an HTTP response body, stdout captured from a command exec'd in a pod, a line from a log file, a value read from a bolt database or embedded JSON asset — and hands it to encoding/json's Unmarshal with a pointer to a target struct. When the bytes are not valid JSON, or are valid JSON whose shape and field types don't fit the target struct, Unmarshal returns a *json.SyntaxError or *json.UnmarshalTypeError, and the calling library wraps it with a family-style message: cilium's "failed to unmarshal bgp state from %s: %w", pulumi's "unmarshalling response object: %w", sops's "Could not unmarshal input data", containerd's "failed to unmarshal object with key %q: %w". The %w wrapping is deliberate: errors.Is and errors.As still reach the original json error, which names the offending byte offset or field type.
Crucially, an unmarshal error is almost never a JSON-parser bug — it is a symptom of something upstream. The records cluster into a few upstream shapes. Network-path interception is the biggest: proxies, captive portals, SSO gateways, and load balancers substitute an HTML error or login page for the JSON the client expected, and charmbracelet/crush's "unmarshal response: %w: %s" even appends the raw body to the message for exactly this diagnosis. Truncation is second: an interrupted download, a killed CLI mid-write, a short read deadline, or ENOSPC yields an empty or cut-off body ("unexpected end of JSON input"). Schema drift is third: the client was compiled against one version of an API or peer process — cilium-cli vs. the Cilium agent, the tailscale Go client vs. a differently-aged tailscaled, Jaeger query vs. documents written by an older collector — and fields were added, renamed, or retyped. Finally, corrupted or hand-edited persisted state (pulumi stack tags and cloud state, lima's event log, containerd's bolt metadata) fails the same way.
From the caller's side the failure looks uniform — a wrapped json error — but the right fix depends on where the bytes came from, and the records show libraries handling this differently. Some treat it as fatal with no recovery; others build in fallbacks, like cilium's metrics loader that retries a failed perDeployNodeMetrics decode as a flat metric list but gives up with "error unmarshalling file" on structural (syntax) errors; Tencent/WeKnora's Notion client treats a missing or empty data_sources key as a legitimate empty result, erroring only on a present-but-mismatched shape. Some surfaces are inherently version-skew detectors: tailscale documents decode failures on localapi endpoints as signals that client and daemon builds diverge, and cilium's in-pod exec helpers fail when the agent inside the pod is older than the CLI invoking it. Others are compile-time defects rather than runtime ones — alibaba/open-code-review unmarshals go:embed'd template JSON, so a parse failure means the source tree shipped a malformed manifest and the fix ends with a rebuild.
Two practical consequences follow for any library in this family. First, the wrapped json error's own text is the most diagnostic string available: "invalid character '<'" means HTML, "unexpected end of JSON input" means empty or truncated, and an UnmarshalTypeError naming a field means a shape or version mismatch. Second, because the check usually happens after a successful read (sometimes even after a 200 status, as in several web-search providers and the GraphQL client in beads), the error often appears where a developer expects HTTP errors — so checking status and Content-Type before decoding, or capturing the raw body on failure, is the recurring prevention advice across these records.
Common causes
- HTML or non-JSON body from a proxy, gateway, or login page. A corporate proxy, captive portal, SSO redirect, or load-balancer error page replaces the expected JSON with HTML, often with a 200 status. The decode fails with "invalid character '<'", which several records (crush, WeKnora, beads, pulumi ESC) call out as the signature of interception.
- Truncated, empty, or partially written payload. A killed process mid-write, an interrupted download, a too-short timeout, or ENOSPC produces an empty or cut-off body that fails with "unexpected end of JSON input". Lima's event log and pulumi's tags files show this for on-disk state; retrying providers show it for HTTP bodies.
- Version skew between client and server/agent schemas. The client's structs were compiled against one API or peer version while the other side is older or newer: cilium-cli vs. the in-pod agent, tailscale client vs. tailscaled, pulumi CLI vs. cloud API, Jaeger query vs. stored documents. Fields added, renamed, or retyped cause UnmarshalTypeError or shape mismatches.
- Wrong-typed or wrongly-shaped fields. Valid JSON that doesn't fit the target struct: a string where a number is expected ("timeout": "30"), a JSON-encoded string instead of an object (double-encoded LLM tool args), an array where a map is required, or a renamed field. Common with LLM-generated tool arguments in pentagi and with API schema drift.
- Hand-edited or corrupted persisted state. Files that libraries expect to write themselves get edited or corrupted: pulumi stack tags and secrets-manager state, lima event logs, containerd's bolt metadata, sops plaintext input (including BOM bytes or trailing commas). The bytes exist but no longer decode into the expected struct.
- Malformed embedded or generated assets. In alibaba/open-code-review, //go:embed'd template JSON fails to decode, meaning the source tree itself shipped invalid JSON (syntax error, BOM) — a build-time defect that requires fixing the file and rebuilding, not a runtime config change.
- Wrong target or missing capability on the far side. Cilium's in-pod exec helpers fail when pointed at a non-agent pod or an agent lacking the queried feature (kvstoremesh only exists in Cilium >= 1.14), so the exec returns an error string or usage text instead of JSON.
What usually fixes it
- Inspect the wrapped json error and, where possible, the raw payload: "invalid character <" points to HTML interception, "unexpected end of JSON input" to truncation, and an UnmarshalTypeError naming a field to a schema or version mismatch. Libraries like crush include the body in the message for exactly this purpose; elsewhere, log the raw body or first fetch into a *[]byte to see what actually arrived.
- Align versions on both sides of the contract. Several records (cilium-cli vs. agent, tailscale client vs. tailscaled, pulumi CLI vs. cloud) resolve only when the client's structs match the peer's response schema, so upgrade or pin both ends together and re-test schema-dependent calls after upgrades.
- Check the network path for interception: verify the endpoint URL and BaseURL point at the real API, bypass or correctly configure proxies and captive portals, confirm Content-Type is application/json, and check HTTP status before decoding instead of relying on the parse to fail.
- Validate and repair persisted inputs before decoding: run jq or python -m json.tool over config/state files, strip BOMs, restore corrupted files from backup or regenerate them, and prefer the library's own write APIs (e.g. pulumi stack tag set) over hand-editing.
- For LLM- or externally-generated JSON, emit typed objects and let an SDK serialize them, validate against the tool's JSON schema before calling, and fix double-encoding so arguments arrive as objects rather than JSON-in-a-string.
- Build decode-failure hygiene into your own code: log a raw-body snippet on every unmarshal failure, keep contract/integration tests that decode recorded real responses to catch schema drift, and treat recurring decode errors as signals of version skew rather than transient noise.
Go deeper
- Connection failures: ECONNREFUSED, ECONNRESET, and friends — why connections get refused, reset, or dropped.
- HTTP status errors: handling 4xx and 5xx responses — how to handle 4xx and 5xx responses properly.
- Parsing and encoding errors: unexpected token, malformed input — why parsers reject input and how to find the real culprit.
Documented occurrences
- failed to unmarshal bgp state from %s: %w (cilium/cilium)
- failed to unmarshal stack tags: %w (pulumi/pulumi)
- failed to unmarshal search arguments: %w (vxcontrol/pentagi)
- failed to parse %s args: %w (vxcontrol/pentagi)
- unmarshal data_sources: %w (Tencent/WeKnora)
- unmarshal response: %w: %s (charmbracelet/crush)
- unmarshalling response object: %w (pulumi/pulumi)
- Could not unmarshal input data: %s (getsops/sops)
- unable to unmarshal response: %w (cilium/cilium)
- unable to unmarshal response of cilium status: %w (cilium/cilium)
- unmarshal block: %w (Tencent/WeKnora)
- unmarshalling response object: %w (pulumi/pulumi)
- invalid dns.OSConfig: %w (tailscale/tailscale)
- unmarshal task_template manifest: %w (alibaba/open-code-review)
- failed to unmarshal bgp routes from %s: %w (cilium/cilium)
- unmarshal default scan template: %w (alibaba/open-code-review)
- parsing existing schedule definition: %w (pulumi/pulumi)
- failed to unmarshal Metaso response: %w (Tencent/WeKnora)
- error unmarshalling file %q: %w (cilium/cilium)
- failed to unmarshal properties for policy %q: %w (pulumi/pulumi)
…and 99 more across the corpus — use search.
Honest provenance: generated on 2026-09-02 from AI-assisted analysis of the linked records. See how records are made.