Go Practice #3 JSON I/O and Input Validation
If #2 Routing shaped the routes, this post fills the inside. The standard pattern for receiving, processing, and responding with data.
Marshal — Go → JSON #
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 #
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 #
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.
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:
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
#
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 #
type Money int64 // cents
func (m Money) MarshalJSON() ([]byte, error) {
return []byte(fmt.Sprintf(`%.2f`, float64(m)/100)), nil
}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.
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
#
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 #
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.
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.
// 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.
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 #
var u User
json.Unmarshal(input, &u) // fills only some fields
// other fields keep their zero valuesParsing doesn’t clear missing fields — old values remain. Always start from a fresh variable, or reset with u = User{}.
Large responses — streaming #
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 dataEncoder/Decoder— natural form in handlers- Tags —
json:"key,omitempty" - Pointer fields — distinguish zero value from absence
DisallowUnknownFields— catch typosMaxBytesReader— 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 →
nullpitfall, 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.