Skip to content
gohacks.dev
Basics

JSON Unmarshal Errors in Go and How to Read Them Fast

Read Go JSON unmarshal errors quickly: type mismatches, syntax errors, non-pointer targets, unknown fields and overflow, with the fix and an HTTP helper.

8 min read
Summarize in AI
Flat illustration of a log with a failing line and a wrench, with the JSON logo.

JSON errors in Go read like riddles until you know the pattern, and then they are among the most informative messages in the standard library. Each one tells you what the data was, what the program expected, and often where. This page walks through the messages you will meet, shows the code that produces each, and ends with a helper that turns them into good responses for an API.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

It belongs to the error handling guide and pairs with custom error types, which shows two of these types in use. Everything ran on Go 1.25.6.

Type mismatch

The most common error is a value of the wrong JSON kind for the Go field:

main.goGo
package main

import (
	"encoding/json"
	"errors"
	"fmt"
)

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

func main() {
	var u User
	err := json.Unmarshal([]byte(`{"name": "Ada", "age": "thirty"}`), &u)
	fmt.Println(err)

	var te *json.UnmarshalTypeError
	if errors.As(err, &te) {
		fmt.Printf("field=%q value=%q type=%v offset=%d\n", te.Field, te.Value, te.Type, te.Offset)
	}
	fmt.Printf("%+v\n", u)
}
Text
json: cannot unmarshal string into Go struct field User.age of type int
field="age" value="string" type=int offset=31
{Name:Ada Age:0}

The message, json: cannot unmarshal string into Go struct field User.age of type int, has four parts: the JSON kind found (string), the Go field (User.age, using the JSON name), the Go type wanted (int), and the error type is *json.UnmarshalTypeError, whose fields give you each one. The last line shows something worth knowing: decoding does not stop at the first mismatch. Name was filled in as "Ada" and Age stayed zero. Always check the error before using the value, because a partly filled struct looks valid.

Other mismatches read the same way: cannot unmarshal object into Go value of type []main.User when the data is an object and you expected an array, cannot unmarshal number into Go value of type string, and cannot unmarshal number 300 into Go struct field Item.small of type int8 when a number does not fit the target type. The cures are on the data side (fix the client) or the type side (use json.Number, string with a ,string tag, or a custom UnmarshalJSON when the format is genuinely inconsistent).

Syntax errors

When the text is not valid JSON, the error is a *json.SyntaxError, and the common messages are these:

main.goGo
package main

import (
	"encoding/json"
	"errors"
	"fmt"
)

func main() {
	inputs := []string{
		`{"a": 1,}`,
		`{'a': 1}`,
		`{"a": 1`,
		`not json`,
		``,
	}
	for _, in := range inputs {
		var v map[string]any
		err := json.Unmarshal([]byte(in), &v)

		var se *json.SyntaxError
		if errors.As(err, &se) {
			fmt.Printf("%-12q %v (offset %d)\n", in, err, se.Offset)
		} else {
			fmt.Printf("%-12q %v\n", in, err)
		}
	}
}
Text
"{\"a\": 1,}" invalid character '}' looking for beginning of object key string (offset 9)
"{'a': 1}"   invalid character '\'' looking for beginning of object key string (offset 2)
"{\"a\": 1"  unexpected end of JSON input (offset 7)
"not json"   invalid character 'o' in literal null (expecting 'u') (offset 2)
""           unexpected end of JSON input (offset 0)

Each input has a classic cause. A trailing comma gives invalid character '}' looking for beginning of object key string. Single quotes give an invalid character error that names the quote itself, because JSON only allows double quotes. A cut-off document gives unexpected end of JSON input. Text that is not JSON at all gives invalid character 'o' in literal null (expecting 'u') for not json, because the decoder started to read n as the start of null. Empty input gives unexpected end of JSON input too, from Unmarshal, while a Decoder reads an empty stream as plain EOF (EOF errors explains the difference).

Offset is a byte position in the input. To show the user where, convert it to a line and column:

Go
line, col := 1, 1
for _, b := range input[:se.Offset] {
	if b == '\n' {
		line, col = line+1, 1
	} else {
		col++
	}
}

That small loop turns "offset 4817" into "line 92, column 14", which is what an editor shows and what a person can act on.

Passing a value instead of a pointer

Unmarshal fills the thing you point at, so it needs a pointer. Forget the & and it tells you so:

main.goGo
package main

import (
	"encoding/json"
	"errors"
	"fmt"
)

type Config struct{ Port int }

func main() {
	var c Config
	err := json.Unmarshal([]byte(`{"Port": 8080}`), c)
	fmt.Println(err)

	var ie *json.InvalidUnmarshalError
	fmt.Println(errors.As(err, &ie))

	var nilPtr *Config
	err = json.Unmarshal([]byte(`{"Port": 8080}`), nilPtr)
	fmt.Println(err)
}
Text
json: Unmarshal(non-pointer main.Config)
true
json: Unmarshal(nil *main.Config)

The first gives json: Unmarshal(non-pointer main.Config), the second json: Unmarshal(nil *main.Config). Both are programming errors (*json.InvalidUnmarshalError), not data errors, so they belong in the "fix the code" pile and should never reach a user. Go vet does not catch them, so a test that decodes one real payload is the cheapest guard.

Fields that vanish quietly

Not every JSON problem is an error, and the silent ones cause more trouble than the loud ones. Two cases cover most: unexported fields and unknown fields.

main.goGo
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"strings"
)

type Order struct {
	ID    int `json:"id"`
	total int // unexported: the json package cannot set it
	Note  string
}

func main() {
	var o Order
	err := json.Unmarshal([]byte(`{"id": 7, "total": 99, "note": "gift", "extra": true}`), &o)
	fmt.Printf("%+v err=%v\n", o, err)

	dec := json.NewDecoder(bytes.NewReader([]byte(`{"id": 7, "extra": true}`)))
	dec.DisallowUnknownFields()
	err = dec.Decode(&o)
	fmt.Println(err)

	_ = strings.ToLower
}
Text
{ID:7 total:0 Note:gift} err=<nil>
json: unknown field "extra"

total stays zero because lowercase field names are unexported, and encoding/json can only set exported fields; a field that you meant to read from the payload but forgot to capitalise never errors, it just stays empty. Note was filled although the JSON key is note: matching is case-insensitive when no tag is given, which is convenient and can surprise. extra is ignored. In an API that wants strict input, Decoder.DisallowUnknownFields() turns unknown keys into errors of the form json: unknown field "extra". Use it for configuration files, where a typo in a key should be loud, and think twice for public APIs, where clients may send fields you add later.

Null, missing and zero

JSON has three states for a value that Go's zero values blur: present, null and absent. A plain int field cannot tell 0 from missing, which matters for PATCH-style updates where absent means "leave alone". Use a pointer (*int), which stays nil when the key is missing or null, or a wrapper type that records whether the key appeared. null decoded into a non-pointer field is a no-op, not an error, so a payload of {"age": null} leaves Age at its previous value; if you reuse a struct between decodes, that can leak the old value into the new request.

Numbers

All JSON numbers decode into float64 when the target is any, which loses precision for large integers: an id of 9007199254740993 comes back as 9007199254740992. Decode into a typed field (int64) or use Decoder.UseNumber() to get a json.Number that keeps the digits. Overflow into a narrow type is an UnmarshalTypeError with the message cannot unmarshal number 300 into Go struct field ... of type int8.

Dates, durations and custom formats

Two types trip up almost every API. A time.Time field decodes from an RFC 3339 string such as "2026-10-26T09:00:00Z"; anything else, including "2026-10-26" or a Unix timestamp, produces a parsing error that begins parsing time "..." as "2006-01-02T15:04:05Z07:00". A time.Duration is an int64 of nanoseconds in JSON, so a client that sends "5s" gets a type mismatch. For both, the cure is a small wrapper type with its own UnmarshalJSON that accepts the format your clients actually send, and returns an error that names the expected format. Write the format into the error message, because the standard text speaks in Go's reference layout, which clients have never seen (time formatting explains that layout).

An HTTP helper that gives good messages

At an API boundary, turn each of these into a 400 with a message the client can use. The decode function is short, and it is where all the types above get used:

decode.goGo
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
)

// DecodeBody decodes JSON from r into dst and explains any failure.
func DecodeBody(r io.Reader, dst any) error {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()

	err := dec.Decode(dst)
	var (
		syntax  *json.SyntaxError
		typeErr *json.UnmarshalTypeError
	)
	switch {
	case err == nil:
		return nil
	case errors.Is(err, io.EOF):
		return errors.New("request body is empty")
	case errors.Is(err, io.ErrUnexpectedEOF):
		return errors.New("request body ends before the JSON is complete")
	case errors.As(err, &syntax):
		return fmt.Errorf("malformed JSON at byte %d", syntax.Offset)
	case errors.As(err, &typeErr):
		return fmt.Errorf("field %q must be %v, got %s", typeErr.Field, typeErr.Type, typeErr.Value)
	default:
		return err // includes unknown field errors, which already name the key
	}
}
main.goGo
package main

import (
	"fmt"
	"strings"
)

type Signup struct {
	Email string `json:"email"`
	Age   int    `json:"age"`
}

func main() {
	for _, body := range []string{
		`{"email": "[email protected]", "age": 30}`,
		``,
		`{"email": "[email protected]", "age": "old"}`,
		`{"email": "[email protected]",}`,
		`{"email": "[email protected]", "age": 30, "admin": true}`,
		`{"email": "[email protected]"`,
	} {
		var s Signup
		fmt.Printf("%-44q -> %v\n", body, DecodeBody(strings.NewReader(body), &s))
	}
}
Text
"{\"email\": \"[email protected]\", \"age\": 30}"        -> <nil>
""                                           -> request body is empty
"{\"email\": \"[email protected]\", \"age\": \"old\"}"   -> field "age" must be int, got string
"{\"email\": \"[email protected]\",}"                    -> malformed JSON at byte 19
"{\"email\": \"[email protected]\", \"age\": 30, \"admin\": true}" -> json: unknown field "admin"
"{\"email\": \"[email protected]\""                      -> request body ends before the JSON is complete

The messages name the field and the expected type, which is what an API client developer needs, and none of them leaks server internals: they describe the request. Wire the result to a 400 or 422 through the HTTP adapter (HTTP error handling). For the full list of types and options, see the Go blog's JSON and Go and the encoding/json package documentation, whose UnmarshalTypeError, SyntaxError and InvalidUnmarshalError entries describe the fields used above.

Also inAPIs
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.