Decoding JSON Request Bodies

Turn a JSON request body into a Go struct with json.Decoder, reject unknown fields, and cap how many bytes you read.

Syntaxdec := json.NewDecoder(r.Body); dec.DisallowUnknownFields(); err := dec.Decode(&v)

A request body is the block of bytes a client sends along with a request. When the client is a program rather than a browser form, that body is usually JSON: text that describes an object as names paired with values. Your handler has to turn it into a Go value before it can use it.

json.NewDecoder takes anything you can read bytes from. r.Body is one of those things. Decode then reads one JSON value and fills in the struct you point it at.

Three guards, in order

Cap the size first. http.MaxBytesReader wraps the body and stops it after the number of bytes you pass. Without it a client can send a gigabyte and your process will try to hold all of it. Then call DisallowUnknownFields on the decoder. A field the struct does not have becomes an error instead of being thrown away in silence, which catches typos in client code. Then check the error from Decode and answer with a 400, rather than letting it surface as a 500.

Shorthand and longhand

json.Unmarshal(data, &v) is the other way in. It needs the whole body in memory first, so the long form is io.ReadAll and then Unmarshal. json.NewDecoder(r.Body).Decode(&v) is the same work in one step, and it reads as it goes. Both produce the same value.

Version note

In Go 1.27 encoding/json is backed by the newer encoding/json/v2 implementation. The behaviour is preserved; the exact wording of error messages can differ from earlier versions.

Example

Example · go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

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

func handle(w http.ResponseWriter, r *http.Request) {
	// Stop reading after 4 KB, whatever the client claims to be sending.
	r.Body = http.MaxBytesReader(w, r.Body, 4<<10)

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields() // a field signup lacks is now an error

	var in signup
	if err := dec.Decode(&in); err != nil {
		http.Error(w, "bad body: "+err.Error(), http.StatusBadRequest)
		return
	}
	fmt.Fprintf(w, "email=%s age=%d\n", in.Email, in.Age)
}

func main() {
	// Stand in for a real client. curl -d '{"email":"[email protected]","age":30}' /signup
	body := strings.NewReader(`{"email":"[email protected]","age":30}`)
	req := httptest.NewRequest(http.MethodPost, "/signup", body)
	rec := httptest.NewRecorder()
	handle(rec, req)
	fmt.Print(rec.Body.String())
}

// Output:
// [email protected] age=30

When to use it

  • Accept a POST /orders body from a mobile app and reject it with 400 when a field name is misspelled.
  • Cap request bodies at 1 MB on a public endpoint so one client cannot exhaust server memory.
  • Read a webhook payload from a payment provider into a struct before verifying its contents.

More examples

The longhand: ReadAll then Unmarshal

json.Unmarshal takes a byte slice, so you have to collect the whole body yourself first. Reach for this form when you need the raw bytes for something else as well, such as checking a signature header over the exact body that was sent. Otherwise prefer the decoder, which does not need the whole body in memory at once.

Example · go
// Same result as json.NewDecoder(r.Body).Decode(&in), written out in full.
data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<10))
if err != nil {
	http.Error(w, "could not read body", http.StatusBadRequest)
	return
}
var in signup
if err := json.Unmarshal(data, &in); err != nil {
	http.Error(w, "bad body", http.StatusBadRequest)
	return
}

Empty bodies and trailing junk

An empty body gives io.EOF, which deserves its own message because the fix is different. The second Decode is the only way to notice a body like {"a":1}garbage, which the first Decode accepts. Add both checks to any endpoint whose clients you do not control.

Example · go
var in signup
switch err := dec.Decode(&in); {
case errors.Is(err, io.EOF):
	// The body was empty. That is a client mistake, not a server one.
	http.Error(w, "a JSON body is required", http.StatusBadRequest)
	return
case err != nil:
	http.Error(w, "malformed JSON", http.StatusBadRequest)
	return
}
// Decode stopped after one value. Insist there is nothing after it.
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
	http.Error(w, "body must contain a single JSON object", http.StatusBadRequest)
	return
}

Turning decode errors into messages a client can act on

errors.As checks whether an error is, or wraps, a particular type and copies it into your variable. json.SyntaxError carries the byte offset, json.UnmarshalTypeError carries the field path, and http.MaxBytesError carries the limit that was passed to MaxBytesReader. Use this in a shared helper so every endpoint gives the same quality of message.

Example · go
var (
	syn   *json.SyntaxError
	mism  *json.UnmarshalTypeError
	tooBig *http.MaxBytesError
)
switch {
case errors.As(err, &syn):
	msg := fmt.Sprintf("malformed JSON at byte %d", syn.Offset)
	http.Error(w, msg, http.StatusBadRequest)
case errors.As(err, &mism):
	// Field is the path to the offending value, e.g. "age".
	msg := fmt.Sprintf("field %q has the wrong type", mism.Field)
	http.Error(w, msg, http.StatusBadRequest)
case errors.As(err, &tooBig):
	msg := fmt.Sprintf("body must be under %d bytes", tooBig.Limit)
	http.Error(w, msg, http.StatusRequestEntityTooLarge)
default:
	http.Error(w, "bad request body", http.StatusBadRequest)
}

Discussion

  • Be the first to comment on this lesson.