# JSON & validation

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

Backend with Go roadmap · Building APIs · 18 min read · https://levelcode.org/roadmaps/go-backend/json-and-validation

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 type | JSON |
| --- | --- |
| `bool` | `true` / `false` |
| integers, floats | number |
| `string` | string (`<`, `>`, `&` escaped as `\u003c` etc.) |
| slice, array | array (nil slice is `null`) |
| `[]byte` | base64-encoded string |
| `map[string]T` | object, keys sorted |
| struct | object of exported fields |
| pointer | the value it points to, or `null` |
| `time.Time` | RFC 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 sent | Message 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"` |

> [!NOTE]
> The unknown-field case matches on the error's text, which the errors lesson warned against. It is a known exception: `encoding/json` has no exported error type for it. Keep the string match in this one helper so there is a single place to update.

## 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

> [!WARNING]
> `encoding/json` matches object keys to struct fields **case-insensitively**: `{"NAME": "x"}` fills a field tagged `json:"name"`. If a key appears twice, the last occurrence wins. Do not rely on either behaviour for security decisions; validate the decoded struct, not the raw JSON.

**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.

**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":"ana@example.com","name":"Ana"}' localhost:8080/signups
{"id":1,"email":"ana@example.com","name":"Ana","created_at":"2026-10-11T09:30:00.271970874Z"}
$ curl -d '{"email":"Ana <ana@example.com>","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":"ana@example.com","name":"Ana","admin":true}' localhost:8080/signups
{"error":"unknown field \"admin\""}
```

Comparing `addr.Address` with the input rejects `Ana <ana@example.com>`, 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`.

## Quiz

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

   A. The field is omitted
   B. "created_at":null
   C. "created_at":"0001-01-01T00:00:00Z"
   D. Encoding fails with an error

   **Answer: C.** omitempty never treats a struct as empty, and time.Time is a struct, so the zero time is encoded. The omitzero option (Go 1.24+) omits zero values and respects IsZero(), which is what you want here.

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?

   A. Decode returns an error
   B. The key is silently ignored and Decode succeeds
   C. The key is added to a hidden map on the struct
   D. The server panics

   **Answer: B.** By default encoding/json ignores unknown keys. Calling dec.DisallowUnknownFields() makes Decode return a 'json: unknown field' error, which catches client typos and attempts to set fields you did not expect.

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

   A. *json.UnmarshalTypeError
   B. *json.SyntaxError
   C. *http.MaxBytesError
   D. io.ErrUnexpectedEOF

   **Answer: A.** The JSON is syntactically valid, but 30.5 cannot be stored in an int, so the decoder reports a *json.UnmarshalTypeError whose Field is "age". SyntaxError is for malformed JSON, and io.ErrUnexpectedEOF for a truncated body.

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

   A. encoding/json cannot decode into types that have an ID field
   B. Request types decode faster
   C. Go requires different types for encoding and decoding
   D. The client can only set fields that exist on the request type, so server-controlled fields like ID cannot be injected

   **Answer: D.** Decoding into the model would let a client send "id" or other internal fields and have them filled in. A request type exposes exactly the fields a client may provide, and DisallowUnknownFields rejects the rest.
