Let's Go Further Parsing JSON requests › JSON decoding
Previous · Contents · Next
Chapter 4.1.

JSON decoding

Just like JSON encoding, there are two approaches that you can take to decode JSON into a native Go value: using a json.Decoder type or using the json.Unmarshal() function.

Both approaches have their pros and cons, but for the purpose of decoding JSON from an HTTP request body, using json.Decoder is generally the best choice. It’s more efficient than json.Unmarshal(), requires less code, and offers some helpful settings that you can use to tweak its behavior.

It’s easiest to demonstrate how json.Decoder works with code, rather than words, so let’s jump straight in and update our createMovieHandler like so:

File: cmd/api/movies.go
package main

import (
    "encoding/json" // New import
    "fmt"
    "net/http"
    "time"

    "greenlight.alexedwards.net/internal/data"
)

...

func (app *application) createMovieHandler(w http.ResponseWriter, r *http.Request) {
    // Declare an anonymous struct to hold the information that we expect to be in the 
    // HTTP request body (note that the field names and types in the struct are a subset
    // of the Movie struct that we created earlier). This struct will be our *target 
    // decode destination*.
    var input struct {
        Title   string   `json:"title"`
        Year    int32    `json:"year"`
        Runtime int32    `json:"runtime"`
        Genres  []string `json:"genres"`
    }

    // Initialize a new json.Decoder instance which reads from the request body, and 
    // then use the Decode() method to decode the body contents into the input struct. 
    // Importantly, notice that when we call Decode() we pass a *pointer* to the input 
    // struct as the target decode destination. If there was an error during decoding,
    // we use our generic errorResponse() helper to send a 400 Bad Request response
    // with the error message to the client.
    err := json.NewDecoder(r.Body).Decode(&input)
    if err != nil {
        app.errorResponse(w, r, http.StatusBadRequest, err.Error())
        return
    }

    // Dump the contents of the input struct in an HTTP response.
    fmt.Fprintf(w, "%+v\n", input)
}

...

There are a few important and interesting things about this code to point out:

OK, let’s take this for a spin.

Fire up the application, then open a second terminal window and make a request to the POST /v1/movies endpoint with a valid JSON request body containing some movie data. You should see a response similar to this:

# Create a BODY variable containing the JSON data that we want to send.
$ BODY='{"title":"Moana","year":2016,"runtime":107, "genres":["animation","adventure"]}'

# Use the -d flag to send the contents of the BODY variable as the HTTP request body.
# Note that curl will default to sending a POST request when the -d flag is used.
$ curl -i -d "$BODY" localhost:4000/v1/movies
HTTP/1.1 200 OK
Date: Tue, 06 Apr 2021 17:13:46 GMT
Content-Length: 65
Content-Type: text/plain; charset=utf-8

{Title:Moana Year:2016 Runtime:107 Genres:[animation adventure]}

Great! That seems to have worked well. We can see from the data dumped in the response that the values we provided in the request body have been decoded into the appropriate fields of our input struct.

Zero values

Let’s take a quick look at what happens if we omit a particular key-value pair in our JSON request body. For example, let’s make a request with no year in the JSON, like so:

$ BODY='{"title":"Moana","runtime":107, "genres":["animation","adventure"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{Title:Moana Year:0 Runtime:107 Genres:[animation adventure]}

As you might have guessed, when we do this the Year field in our input struct is left with its zero value (which happens to be 0 because the Year field is an int32 type).

This leads to an interesting question: how can you tell the difference between a client not providing a key-value pair, and providing a key-value pair but deliberately setting it to its zero value? Like this:

$ BODY='{"title":"Moana","year":0,"runtime":107, "genres":["animation","adventure"]}'
$ curl -d "$BODY" localhost:4000/v1/movies
{Title:Moana Year:0 Runtime:107 Genres:[animation adventure]}

The end result is the same, despite the different HTTP requests, and it’s not immediately obvious how to tell the difference between the two scenarios. We’ll circle back to this topic later in the book, but for now, it’s worth just being aware of this behavior.


Additional information

Supported destination types

It’s important to mention that certain JSON types can only be successfully decoded to certain Go types. For example, if you have the JSON string "foo" it can be decoded into a Go string, but trying to decode it into a Go int or bool will result in an error at runtime (as we’ll demonstrate in the next chapter).

The following table shows the supported target decode destinations for the different JSON types:

JSON type Supported Go types
JSON boolean bool
JSON string string
JSON number int*, uint*, float*, rune
JSON array array, slice
JSON object struct, map

Using the json.Unmarshal function

As we mentioned at the start of this chapter, it’s also possible to use the json.Unmarshal() function to decode an HTTP request body.

For example, you could use it in a handler like this:

func (app *application) exampleHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Foo string `json:"foo"`
    }

    // Use io.ReadAll() to read the entire request body into a []byte slice.
    body, err := io.ReadAll(r.Body)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }
    
    // Use the json.Unmarshal() function to decode the JSON in the []byte slice to the
    // input struct. Again, notice that we are using a *pointer* to the input
    // struct as the decode destination.
    err = json.Unmarshal(body, &input)
    if err != nil {
        app.errorResponse(w, r, http.StatusBadRequest, err.Error())
        return
    }

    fmt.Fprintf(w, "%+v\n", input)
}

...

Using this approach is fine — the code works, and it’s clear and simple. But it doesn’t offer any benefits over and above the json.Decoder approach that we’re already taking.

Not only is the code marginally more verbose, but it’s also less efficient. If we benchmark the relative performance for this particular use case, we can see that using json.Unmarshal() requires about 80% more memory (B/op) than json.Decoder, as well as being a tiny bit slower (ns/op).

$ go test -run=^$ -bench=. -benchmem -count=3 -benchtime=5s
goos: linux
goarch: amd64
BenchmarkUnmarshal-8      528088      9543 ns/op     2992 B/op     20 allocs/op
BenchmarkUnmarshal-8      554365     10469 ns/op     2992 B/op     20 allocs/op
BenchmarkUnmarshal-8      537139     10531 ns/op     2992 B/op     20 allocs/op
BenchmarkDecoder-8        811063      8644 ns/op     1664 B/op     21 allocs/op
BenchmarkDecoder-8        672088      8529 ns/op     1664 B/op     21 allocs/op
BenchmarkDecoder-8       1000000      7573 ns/op     1664 B/op     21 allocs/op

Additional JSON decoding nuances

There are a few JSON decoding nuances that are important or interesting to know about, but which don’t fit nicely into the main content of this book. I’ve included this appendix which explains and demonstrates them in detail.