Step 10 of 24

JSON & validation

Encode responses with encoding/json, decode request bodies defensively, and validate input before it reaches your business logic.

18 min 8 sections

Almost every API speaks JSON, and many API bugs start with trusting input. This lesson covers both directions: encoding Go values with struct tags (including Go 1.24's omitzero), writing responses safely, decoding request bodies with size limits and strict field checks, turning decoder errors into helpful messages, and validating the result.

Encoding Go values to JSON

json.Marshal converts a Go value into JSON bytes. For structs, struct tags control the JSON names and options:

go
type Product struct {
	ID         int64     `json:"id"`
	Name       string    `json:"name"`
	PriceCents int       `json:"price_cents"`
	Tags       []string  `json:"tags"`
	Discount   float64   `json:"discount,omitempty"`
	ReleasedAt time.Time `json:"released_at,omitzero"`
	SupplierID int64     `json:"-"`

	internalNote string // unexported: never encoded
}

func main() {
	b, err := json.Marshal(Product{ID: 1, Name: "Desk lamp", PriceCents: 2499})
	if err != nil {
		panic(err)
	}
	fmt.Println(string(b))
}
text
{"id":1,"name":"Desk lamp","price_cents":2499,"tags":null}

What the tags did:

  • json:"price_cents" renames the field. Without a tag, the Go name PriceCents is used as is.
  • json:"-" excludes a field entirely. Use it for anything the client must never see.
  • Unexported fields are invisible to encoding/json, tag or no tag. A field named name string silently disappears from your output, a classic first-day bug.
  • omitempty omits the field when it is false, 0, "", a nil pointer or interface, or an empty slice or map.
  • omitzero (Go 1.24+) omits the field when it is the zero value of its type, or when its IsZero() method returns true. This finally handles structs like time.Time: omitempty never omits a struct, so a zero time used to appear as "0001-01-01T00:00:00Z".

Notice "tags":null. A nil slice encodes as null, while an empty slice []string{} encodes as []. Clients written in JavaScript or TypeScript often crash on null where they expect an array, so initialise slices you return in responses.

Go typeJSON
booltrue / false
integers, floatsnumber
stringstring (<, >, & escaped as \u003c etc.)
slice, arrayarray (nil slice is null)
[]bytebase64-encoded string
map[string]Tobject, keys sorted
structobject of exported fields
pointerthe value it points to, or null
time.TimeRFC 3339 string, e.g. "2026-10-11T09:00:00Z"

Types can customise their encoding by implementing MarshalJSON() ([]byte, error), or MarshalText for values that should appear as strings.

Writing JSON responses

A response helper keeps every handler consistent:

go
func writeJSON(w http.ResponseWriter, status int, v any) {
	b, err := json.Marshal(v)
	if err != nil {
		slog.Error("encode response", "err", err)
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	w.Write(append(b, '\n'))
}

Marshalling into memory before touching the ResponseWriter matters. The common alternative, w.WriteHeader(status) followed by json.NewEncoder(w).Encode(v), sends the status before encoding starts, so if encoding fails, for example on a NaN float (json: unsupported value: NaN), the client is left with a 200 and an empty body. With Marshal first, a failure can still become a clean 500. (Encoder does not stream either: it builds each value fully in memory before writing it.)

Decoding request bodies safely

Decoding is where untrusted data enters. A naive json.NewDecoder(r.Body).Decode(&v) has three problems: no size limit, unknown fields are silently ignored (so a client's typo like "nmae" "succeeds" with an empty name), and anything after the first JSON value is ignored. Fix all three in one generic helper:

go
const maxBodyBytes = 1 << 20 // 1 MiB

func decodeJSON[T any](w http.ResponseWriter, r *http.Request) (T, error) {
	var v T
	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := dec.Decode(&v); err != nil {
		return v, describeJSONError(err)
	}
	// A second Decode must hit EOF: reject `{"a":1} {"b":2}` and trailing junk.
	if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		return v, errors.New("body must contain a single JSON object")
	}
	return v, nil
}

The type parameter lets each handler say what it expects, in, err := decodeJSON[createProductRequest](w, r), and get a typed value back. MaxBytesReader makes reads fail once the limit is exceeded and tells the server to close the connection, so a client cannot stream gigabytes at you.

If decoding fails, throw the value away. encoding/json fills in what it can before reporting an error, so v may be half-populated.

Turning decode errors into useful messages

The raw errors are written for Go developers (json: cannot unmarshal number into Go struct field createProductRequest.name of type string) and leak your type names. Translate them:

go
func describeJSONError(err error) error {
	var syntaxErr *json.SyntaxError
	var typeErr *json.UnmarshalTypeError
	var maxErr *http.MaxBytesError

	switch {
	case errors.Is(err, io.EOF):
		return errors.New("body must not be empty")
	case errors.As(err, &syntaxErr):
		return fmt.Errorf("malformed JSON at byte %d", syntaxErr.Offset)
	case errors.Is(err, io.ErrUnexpectedEOF):
		return errors.New("malformed JSON: body ended early")
	case errors.As(err, &typeErr):
		if typeErr.Field == "" {
			return errors.New("body must be a JSON object")
		}
		return fmt.Errorf("field %q must be of type %s", typeErr.Field, typeErr.Type)
	case errors.As(err, &maxErr):
		return fmt.Errorf("body must not be larger than %d bytes", maxErr.Limit)
	case strings.HasPrefix(err.Error(), "json: unknown field "):
		return fmt.Errorf("unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field "))
	default:
		return err
	}
}
Body sentMessage returned
(empty)body must not be empty
{"name": "a",}malformed JSON at byte 14
{"name":malformed JSON: body ended early
{"name": 5}field "name" must be of type string
{"address": {"city": 5}}field "address.city" must be of type string
[1]body must be a JSON object
{"nmae": "x"}unknown field "nmae"

Validating input

Decoding checks that the JSON has the right shape. Validation checks that the values make sense for your domain: required fields present, lengths and ranges respected. Keep the two separate. A 400 Bad Request says "I could not parse that"; a 422 Unprocessable Content says "I parsed it, and here is what is wrong with each field".

A tiny validator collects every problem rather than stopping at the first, so a client can fix a form in one round trip:

go
// Validator maps a JSON field name to a problem with it.
type Validator map[string]string

// Check records msg for field if ok is false (keeping the first problem per field).
func (v Validator) Check(ok bool, field, msg string) {
	if _, exists := v[field]; !ok && !exists {
		v[field] = msg
	}
}

type createProductRequest struct {
	Name       string   `json:"name"`
	PriceCents int      `json:"price_cents"`
	Tags       []string `json:"tags"`
}

func (in *createProductRequest) Validate() Validator {
	v := Validator{}
	in.Name = strings.TrimSpace(in.Name) // normalise before checking
	v.Check(in.Name != "", "name", "must be provided")
	v.Check(utf8.RuneCountInString(in.Name) <= 100, "name", "must be at most 100 characters")
	v.Check(in.PriceCents > 0, "price_cents", "must be greater than zero")
	v.Check(len(in.Tags) <= 5, "tags", "must have at most 5 entries")
	v.Check(!slices.Contains(in.Tags, ""), "tags", "must not contain empty strings")
	return v
}

The handler now reads as a clear sequence: decode, validate, act, respond.

go
func (a *app) createProduct(w http.ResponseWriter, r *http.Request) {
	in, err := decodeJSON[createProductRequest](w, r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	if problems := in.Validate(); len(problems) > 0 {
		writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"errors": problems})
		return
	}
	p := a.store.Create(in.Name, in.PriceCents, in.Tags)
	writeJSON(w, http.StatusCreated, p)
}
text
$ curl -i -H 'Content-Type: application/json' -d '{"name":"  ","price_cents":-5}' localhost:8080/products
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{"errors":{"name":"must be provided","price_cents":"must be greater than zero"}}

Notice that a separate request type (createProductRequest) is decoded, not the Product model. The client cannot set ID or other server-controlled fields because they simply do not exist on the request type. The next lesson standardises the error body shape across a whole API.

Decoding gotchas

Missing versus zero. {"price_cents": 0} and {} both decode to PriceCents == 0. When the difference matters, as with optional fields or partial updates (PATCH), use a pointer: a *int stays nil when the key is absent.

go
type updateProductRequest struct {
	Name       *string `json:"name"`
	PriceCents *int    `json:"price_cents"`
}

// Only touch the fields the client actually sent:
// if in.Name != nil { p.Name = *in.Name }

Numbers in any. Decoding into any or map[string]any turns every number into float64, so 12345678901234567890 silently becomes 1.2345678901234567e+19. Prefer concrete struct types; if you must decode arbitrary JSON, call dec.UseNumber() to get exact json.Number strings.

Practice

Build POST /signups that accepts {"email": "...", "name": "...", "age": 30}:

  • Decode with decodeJSON, responding 400 with {"error": ...} on failure.
  • email is required and must parse with net/mail.ParseAddress as a bare address (no display name).
  • name is required after trimming and at most 50 characters.
  • age is optional; if present it must be between 13 and 130.
  • On success respond 201 with {"id", "email", "name", "age", "created_at"}, omitting age when it was not sent.
Show solution

This uses writeJSON, decodeJSON, describeJSONError and Validator from the lesson.

go
type signupRequest struct {
	Email string `json:"email"`
	Name  string `json:"name"`
	Age   *int   `json:"age"` // pointer: absent and 0 are different
}

func (in *signupRequest) Validate() Validator {
	v := Validator{}
	in.Email = strings.TrimSpace(in.Email)
	in.Name = strings.TrimSpace(in.Name)

	v.Check(in.Email != "", "email", "must be provided")
	addr, err := mail.ParseAddress(in.Email)
	v.Check(err == nil && addr.Address == in.Email, "email", "must be a valid email address")

	v.Check(in.Name != "", "name", "must be provided")
	v.Check(utf8.RuneCountInString(in.Name) <= 50, "name", "must be at most 50 characters")

	if in.Age != nil {
		v.Check(*in.Age >= 13 && *in.Age <= 130, "age", "must be between 13 and 130")
	}
	return v
}

type signup struct {
	ID        int64     `json:"id"`
	Email     string    `json:"email"`
	Name      string    `json:"name"`
	Age       *int      `json:"age,omitzero"` // nil pointer is the zero value
	CreatedAt time.Time `json:"created_at"`
}

type signups struct {
	mu     sync.Mutex
	nextID int64
}

func (s *signups) create(w http.ResponseWriter, r *http.Request) {
	in, err := decodeJSON[signupRequest](w, r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	if problems := in.Validate(); len(problems) > 0 {
		writeJSON(w, http.StatusUnprocessableEntity, map[string]any{"errors": problems})
		return
	}

	s.mu.Lock()
	s.nextID++
	id := s.nextID
	s.mu.Unlock()

	out := signup{ID: id, Email: in.Email, Name: in.Name, Age: in.Age, CreatedAt: time.Now().UTC()}
	writeJSON(w, http.StatusCreated, out)
}

func main() {
	s := &signups{}
	mux := http.NewServeMux()
	mux.HandleFunc("POST /signups", s.create)
	srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
	if err := srv.ListenAndServe(); err != nil {
		slog.Error("server stopped", "err", err)
		os.Exit(1)
	}
}
text
$ curl -d '{"email":"[email protected]","name":"Ana"}' localhost:8080/signups
{"id":1,"email":"[email protected]","name":"Ana","created_at":"2026-10-11T09:30:00.271970874Z"}
$ curl -d '{"email":"Ana <[email protected]>","name":"","age":7}' localhost:8080/signups
{"errors":{"age":"must be between 13 and 130","email":"must be a valid email address","name":"must be provided"}}
$ curl -d '{"email":"[email protected]","name":"Ana","admin":true}' localhost:8080/signups
{"error":"unknown field \"admin\""}

Comparing addr.Address with the input rejects Ana <[email protected]>, which ParseAddress accepts but is not a bare address. Rejecting unknown fields stops clients slipping in fields like admin.

Key takeaways

  • Only exported fields are encoded; tags rename (json:"name"), hide (json:"-") and omit (omitempty, and omitzero for zero structs like time.Time).
  • Nil slices encode as null; return empty slices when clients expect arrays.
  • Marshal into memory before writing headers so encoding failures can still become a 500.
  • Decode with a size limit (http.MaxBytesReader), DisallowUnknownFields, and a check for trailing data; translate decoder errors into client-friendly messages.
  • Validate decoded values separately, report all field problems with 422, and decode into request-specific types so clients cannot set server-controlled fields.
  • Use pointers to tell "absent" from "zero", and avoid decoding numbers into any.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.A struct has CreatedAt time.Time `json:"created_at,omitempty"`, and CreatedAt is the zero time. What is encoded?

  2. 2.With a plain json.NewDecoder(r.Body).Decode(&in), what happens when the client sends a key that does not exist on the struct?

  3. 3.A client sends {"age": 30.5} to a struct with Age int. Which error type can you extract with errors.As?

  4. 4.Why do the examples decode into a separate createProductRequest type rather than directly into the Product model?

Finished reading?

Mark it complete to fill in your roadmap.