In Chapter 16, we wired four routes on an explicit mux, but the task handlers were stubs: listTasks printed a hard-coded JSON string and createTask returned plain text. This chapter makes those handlers speak real JSON using Go's encoding/json package.
The encoding/json package converts Go values to JSON on the way out, and parses JSON from a client back into Go values on the way in. We will add a Task struct with tags that control exactly how its fields appear on the wire. Then, we will build a reusable writeJSON helper that every JSON handler in the course can call.

Here is what changes in this chapter:
Task struct with json:"..." tags that rename fields, drop empty values, and exclude internal data.writeJSON(w, status, v) helper that sets Content-Type: application/json in one place.listTasks, getTask, and createTask now encode real Go types instead of hard-coded strings.Check out the finish branch to follow along:
bashgit checkout 17-working-with-json-finish
The encoding/json package gives you two pairs of functions for working with JSON.
json.Marshal(v) returns a []byte. json.Unmarshal(data, &v) parses those bytes back into a Go value. Both work entirely in memory. They are useful when you need the raw bytes to write JSON to a file, embed it in a database column, or compare data in a test.
For HTTP handlers, the streaming alternatives fit better. json.NewEncoder(w).Encode(v) writes JSON directly to any io.Writer. json.NewDecoder(r.Body).Decode(&v) reads JSON from any io.Reader. Because http.ResponseWriter and r.Body are both streams, you do not have to build and hold an intermediate []byte yourself. This is why writeJSON and createTask use the Encoder and Decoder.
The Task struct is the central data type for this course:
go// main.gotype Task struct {ID int `json:"id"`Title string `json:"title"`Description string `json:"description,omitempty"`Done bool `json:"done"`Internal string `json:"-"`}
The backtick strings are struct tags. These are pieces of metadata attached to a field that encoding/json reads at runtime. Three distinct effects appear in this struct.
json:"id" renames the key on the wire. Go convention spells acronyms in uppercase (ID), but JSON APIs typically use lowercase (id). The tag bridges the two formats without renaming the Go field.
json:"description,omitempty" adds the omitempty option. When Description is an empty string (the zero value for string), the key is omitted from the JSON output entirely. You will see this in the list response later. Task 1 has no description key because the field is empty, while task 2 includes it because the field is set.
json:"-" excludes a field from JSON in both directions. Internal is set to "seed" in the list handler's seed data, but it never appears in any response. A client sending "internal":"..." in a POST body cannot set it either.
There is one capitalization rule that trips up almost every Go beginner.
The encoding/json package only sees exported (capitalized) fields. A lowercase field is silently invisible in both directions, and Go will not throw an error:
gotype Task struct {Title string // exported → appears in JSON (or as "title" with a tag)done bool // lowercase → encoding/json ignores it completely}
The done field is never encoded into output. If a client sends "done":true, that field is quietly discarded after decoding. The first sign of this bug is usually a JSON response with fewer keys than you expected and nothing in the logs explaining why.
This is the same exported-vs-unexported rule from Chapter 7. The consequence here is new: a lowercase struct field is invisible to encoding/json regardless of which package the code runs in. Our actual Task struct has all fields capitalized, so this is just a cautionary example.
Every JSON response in our application will go through a single helper function:
go// main.gofunc writeJSON(w http.ResponseWriter, status int, v any) {w.Header().Set("Content-Type", "application/json")w.WriteHeader(status)if err := json.NewEncoder(w).Encode(v); err != nil {log.Printf("writeJSON: %v", err)}}
The order follows the rule from Chapter 15. Set headers first, write the status code, and then write the body. If you flip the first two lines, the header change is ignored because the HTTP response has already started.
Content-Type: application/json is set exactly once in this helper for every JSON response in the course. Contrast this with our ping handler, which never calls writeJSON. When fmt.Fprintln writes to a response writer and no content type is set, Go sniffs the bytes and sets text/plain; charset=utf-8 automatically. This is why GET /ping and GET /tasks return different Content-Type headers from the same server.
The encode error is handled and logged via log.Printf. In practice, an encode error usually means the client connection dropped. Ignoring it entirely, however, could hide a real problem.
With the struct and helper in place, the three task handlers become straightforward.
listTasks builds a hard-coded slice and passes it to writeJSON:
go// main.gofunc listTasks(w http.ResponseWriter, r *http.Request) {tasks := []Task{{ID: 1, Title: "Write the JSON chapter", Done: true, Internal: "seed"},{ID: 2, Title: "Record the demo", Description: "curl every route", Done: false},}writeJSON(w, http.StatusOK, tasks)}
Task 1 has no Description, so omitempty drops the key. Internal: "seed" is set in the Go code but tagged with json:"-", so it never reaches the wire. An in-memory store will replace this hard-coded slice later in the course.
getTask parses the {id} wildcard into an integer and returns a single task:
go// main.gofunc getTask(w http.ResponseWriter, r *http.Request) {id, err := strconv.Atoi(r.PathValue("id"))if err != nil {http.Error(w, "invalid task id", http.StatusBadRequest)return}task := Task{ID: id, Title: "Sample task", Done: false}writeJSON(w, http.StatusOK, task)}
Chapter 16's version captured the ID as a raw string. Now, strconv.Atoi converts it to an int to match Task.ID. A non-numeric ID like /tasks/abc returns a 400 Bad Request before any encoding happens.
createTask decodes the request body and echoes the result back:
go// main.gofunc createTask(w http.ResponseWriter, r *http.Request) {var t Taskif err := json.NewDecoder(r.Body).Decode(&t); err != nil {http.Error(w, "invalid JSON body", http.StatusBadRequest)return}writeJSON(w, http.StatusCreated, t)}
On success, the decoded task is echoed back with a 201 Created status. Notice the echo includes "id":0. The client did not send an ID, and nothing generates one yet. Chapter 20 adds an in-memory store that assigns an incrementing ID on each create.
Start the server with go run . and try the key routes:
bashcurl -si http://localhost:8080/taskscurl -si -X POST http://localhost:8080/tasks \-H 'Content-Type: application/json' -d '{"title":"Buy milk","description":"2%","done":false}'curl -si -X POST http://localhost:8080/tasks -d 'not json'

The GET /tasks response has description only on the task where it is set, and no internal field on either task. The POST with a valid body comes back 201 with "id":0. The malformed body gets a plain-text 400.
Three common situations cause json.NewDecoder(r.Body).Decode(&t) to return an error.
An empty body returns io.EOF. This is the most common case, such as a POST request with no body at all.
A type mismatch is also rejected. Sending "done":"yes" when Done is a bool fails because "yes" is a string, not a JSON boolean.
Unknown fields are silently ignored by default. A field in the JSON that has no matching exported struct field is dropped without any error. If you want stricter decoding to catch typos in field names, you can opt in:
godec := json.NewDecoder(r.Body)dec.DisallowUnknownFields()if err := dec.Decode(&t); err != nil { ... }
The finish-branch code does not enable this strict decoding. The current 400 error is a plain-text response via http.Error. Chapter 22 builds a consistent JSON error envelope so clients can parse error details programmatically. A plain 400 is the right amount of machinery for now.
Chapter 18 puts a spotlight on context.Context, Go's mechanism for carrying a deadline, a cancellation signal, and request-scoped values from a handler down to a database query. Every http.Request already carries one via r.Context().