Go Practice #3 JSON I/O and Input Validation

6 min read

If #2 Routing shaped the routes, this post fills the inside. The standard pattern for receiving, processing, and responding with data.

Marshal — Go → JSON #

basic Marshal
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

u := User{ID: 1, Name: "Dokyung Lee"}
b, err := json.Marshal(u)
// {"id":1,"name":"Dokyung Lee"}

Two essentials:

  • struct tag — specifies the key with json:"name"
  • omitempty — omits the field entirely when it’s the zero value

Indentation #

b, _ := json.MarshalIndent(u, "", "  ")

For responses, usually Marshal (traffic), and MarshalIndent for debugging output.

Unmarshal — JSON → Go #

basic Unmarshal
input := []byte(`{"id":1,"name":"Dokyung Lee"}`)

var u User
if err := json.Unmarshal(input, &u); err != nil {
	// ...
}

Passing a pointer is key. Unknown fields are silently ignored.

In an HTTP handler — Encoder/Decoder fits better #

standard handler pattern
func createUser(w http.ResponseWriter, r *http.Request) {
	var u User
	if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	saved := saveUser(u)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(saved)
}

Decoder.Decode — reads directly from io.Reader (no need to load all bytes). Memory-efficient.

Common pitfalls #

1) Can’t distinguish zero value from “key absent” #

type User struct {
	Active bool `json:"active"`
}

Whether the JSON has "active":false or no key at all — Go sees both as false. To tell them apart, use a pointer.

express absence with nil
type User struct {
	Active *bool `json:"active"`
}

if u.Active != nil && *u.Active {
	// only when explicitly true was sent
}

Common in partial-update (PATCH) APIs.

2) Reject unknown fields #

The default — unknown fields are ignored. To reject explicitly:

DisallowUnknownFields
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&u); err != nil {
	// ...
}

Useful for catching typos. Especially in PATCH APIs.

3) Large input — MaxBytesReader #

limit request size
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)    // 1 MiB

var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
	// too big or invalid JSON
}

Introduced in #1. Always capping the input size is safer.

4) Time — time.Time’s default format #

type Event struct {
	At time.Time `json:"at"`
}

time.Time defaults to RFC 3339 ("2026-02-12T10:00:00Z"). For another format — make a custom type and write MarshalJSON/UnmarshalJSON.

Custom marshaling #

MarshalJSON
type Money int64    // cents

func (m Money) MarshalJSON() ([]byte, error) {
	return []byte(fmt.Sprintf(`%.2f`, float64(m)/100)), nil
}
UnmarshalJSON
func (m *Money) UnmarshalJSON(b []byte) error {
	var f float64
	if err := json.Unmarshal(b, &f); err != nil {
		return err
	}
	*m = Money(f * 100)
	return nil
}

The standard pattern for encapsulating serialization rules in domain types.

Input validation — explicit in code #

The question is whether the received data is valid. JSON parsing only checks format, not business rules.

simple validation
type CreateUser struct {
	Name  string `json:"name"`
	Email string `json:"email"`
	Age   int    `json:"age"`
}

func (c CreateUser) Validate() error {
	if c.Name == "" {
		return errors.New("name required")
	}
	if !strings.Contains(c.Email, "@") {
		return errors.New("invalid email")
	}
	if c.Age < 0 || c.Age > 150 {
		return errors.New("invalid age")
	}
	return nil
}

func handler(w http.ResponseWriter, r *http.Request) {
	var c CreateUser
	if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	if err := c.Validate(); err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	// ...
}

In a small API — a manual method is the clearest. When manuals become a burden, consider tooling.

Validation library — go-playground/validator #

tag-based validation
import "github.com/go-playground/validator/v10"

type CreateUser struct {
	Name  string `json:"name" validate:"required,min=2,max=50"`
	Email string `json:"email" validate:"required,email"`
	Age   int    `json:"age" validate:"gte=0,lte=150"`
}

var v = validator.New()

if err := v.Struct(c); err != nil {
	// validator.ValidationErrors lets you extract the specific violations
}

Pro — short, tag-based expression. Con — magical behavior, error handling is somewhat involved (gathering many-field violations). Often adopted in big APIs.

Responses — consistent error shape #

standardize error responses
type ErrorResponse struct {
	Error   string            `json:"error"`
	Details map[string]string `json:"details,omitempty"`
}

func writeError(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(ErrorResponse{Error: msg})
}

That way, API users only need to learn the error shape once.

per-field details
writeError(w, 400, "validation failed")
// or with details
writeError(w, 400, "validation failed",
	map[string]string{"email": "invalid format"})

A common pitfall in Bool / Int responses — nil slice #

type Resp struct {
	Items []Item `json:"items"`
}

var r Resp
json.Marshal(r)
// {"items":null}

Serialized as null instead of an empty array. Clients that don’t handle null will break.

Solution — explicitly create an empty slice.

r.Items = []Item{}
// {"items":[]}

Pattern — DTO separation #

Don’t serialize domain types as-is — separate API-exposed DTOs.

DTO separation
// internal domain
type User struct {
	ID           int
	Email        string
	PasswordHash string    // ← must not be exposed via API
	CreatedAt    time.Time
}

// response DTO
type UserDTO struct {
	ID    int    `json:"id"`
	Email string `json:"email"`
}

func toDTO(u User) UserDTO {
	return UserDTO{ID: u.ID, Email: u.Email}
}

You can also exclude a field with json:"-" — but DTO separation is clearer in intent and protects the API from breaking when internal structure changes.

Response helpers #

Used often, they shorten handlers.

response helpers
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func decodeJSON(r *http.Request, v any) error {
	r.Body = http.MaxBytesReader(nil, r.Body, 1<<20)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	return dec.Decode(v)
}

With just these two, handler bodies are reduced to the essentials.

Pitfall — leftover zero values when reusing #

common pitfall
var u User
json.Unmarshal(input, &u)    // fills only some fields
// other fields keep their zero values

Parsing doesn’t clear missing fields — old values remain. Always start from a fresh variable, or reset with u = User{}.

Large responses — streaming #

streaming a large result
func handler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	enc := json.NewEncoder(w)

	for row := range queryRows() {    // channel/iterator
		if err := enc.Encode(row); err != nil {
			return
		}
	}
}

That’s the JSON Lines (one per line) format — stream large results without loading everything into memory. Also well-known as ndjson.

Wrap-up #

What we covered:

  • json.Marshal / Unmarshal — small data
  • Encoder / Decoder — natural form in handlers
  • Tagsjson:"key,omitempty"
  • Pointer fields — distinguish zero value from absence
  • DisallowUnknownFields — catch typos
  • MaxBytesReader — always bound input size
  • Validation — explicitly separated in handlers; consider validator for big APIs
  • DTO separation — don’t expose internal types as-is
  • Empty slice → null pitfall, fix with explicit []Item{}
  • JSON Lines for streaming large responses

In the next post (#4 DB Integration) we cover — database/sql, prepared statements, transactions, and other standard data-persistence patterns.

X