Introduction
Go's json.Unmarshal() silently ignores JSON fields that do not map to struct fields. This means API changes that add, rename, or typo fields go undetected -- the struct gets zero values for missing fields with no error. This is a silent failure mode that can cause subtle bugs in production where required data is missing but no error is raised. The solution is to use json.Decoder.DisallowUnknownFields() which rejects JSON containing unexpected fields, or to implement custom validation logic.
Symptoms
go
type User struct {
Name string json:"name"
Email string json:"email"`
}
// JSON has typo: "emial" instead of "email"
data := []byte({"name": "Alice", "emial": "alice@example.com"})
var user User
json.Unmarshal(data, &user)
// user.Email is "" - silently ignored! No error returned.
```
Or missing new fields:
// API adds new required field "role"
data := []byte(`{"name": "Bob", "email": "bob@example.com", "role": "admin"}`)
// If struct doesn't have Role field, "role" is silently discardedCommon Causes
- json.Unmarshal ignores unknown fields: Default behavior drops unrecognized fields
- Field name typo in JSON client: Sending field with wrong name
- API version mismatch: Client struct out of date with server schema
- Missing json tags: Field name casing mismatch between Go and JSON
- Nested struct missing fields: Inner struct does not have all expected fields
- Embedded struct field shadowing: Embedded field with same name takes precedence
Step-by-Step Fix
Step 1: Use json.Decoder with DisallowUnknownFields
```go func strictUnmarshal(data []byte, v interface{}) error { dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() return dec.Decode(v) }
// Usage
type User struct {
Name string json:"name"
Email string json:"email"
}
data := []byte({"name": "Alice", "emial": "alice@example.com"})
var user User
err := strictUnmarshal(data, &user)
// Returns error: json: unknown field "emial"
```
Step 2: Handle API versioning with flexible structs
go
type UserV1 struct {
Name string json:"name"
Email string json:"email"`
}
type UserV2 struct {
Name string json:"name"
Email string json:"email"
Role string json:"role"
}
func parseUser(data []byte) (interface{}, error) { // First try V2 (strict) var v2 UserV2 dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(&v2); err == nil { return v2, nil }
// Fall back to V1 var v1 UserV1 if err := json.Unmarshal(data, &v1); err != nil { return nil, fmt.Errorf("failed to parse as any known version: %w", err) } return v1, nil } ```
Step 3: Add map-based catch-all for debugging
go
type FlexibleUser struct {
Name string json:"name"
Email string json:"email"
Extra map[string]any json:"-"` // Catches unknown fields
}
func (u *FlexibleUser) UnmarshalJSON(data []byte) error { // First unmarshal into a map to capture all fields var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return err }
// Extract known fields if name, ok := raw["name"].(string); ok { u.Name = name } if email, ok := raw["email"].(string); ok { u.Email = email }
// Keep remaining fields delete(raw, "name") delete(raw, "email") if len(raw) > 0 { log.Printf("Unknown fields received: %v", raw) u.Extra = raw }
return nil } ```
Prevention
- Use
json.Decoder.DisallowUnknownFields()for API input validation - Add contract tests that verify JSON schemas match Go structs
- Use tools like
go-jsonschemato generate Go types from JSON Schema definitions - Log unknown fields during development to catch API changes early
- Version your API structs when fields are added or removed
- Use
omitemptyon json tags to handle optional fields gracefully - Add integration tests that send and receive real API payloads