The decoder is the validator
You receive a request body. It claims to be JSON. Your job is to turn that claim into Go data without crashing. Go makes this straightforward. You don't write a separate validator. You write a decoder. The decoder is the validator.
The encoding/json package provides json.Unmarshal. You pass it bytes and a pointer to a destination variable. It returns an error if anything goes wrong. That's the entire contract. If the error is nil, the JSON is syntactically correct and matches the Go type you provided. The data is now in your variable.
This approach couples parsing and validation. In other languages, you might parse to a generic object, then run a schema check against that object. Go skips the middleman. You define the shape you want. The decoder enforces it. If the JSON doesn't fit the shape, the decode fails. You get one step that handles both concerns.
Validation in Go happens at the boundary. You validate when you cross from bytes to values. You don't validate later. You don't trust the data until it's inside a typed variable.
Minimal validation with a map
Sometimes you don't know the shape of the JSON ahead of time. You just need to know if the bytes are well-formed. You can unmarshal into a map[string]interface{}. This generic map accepts any valid JSON object. The decoder builds the structure dynamically.
Here's the baseline check. You have raw bytes and you need to know if they are valid JSON syntax.
package main
import (
"encoding/json"
"fmt"
)
func main() {
// Raw bytes from a request or file.
data := []byte(`{"name": "Alice", "score": 99}`)
// map[string]interface{} accepts any valid JSON object structure.
// The decoder creates Go values for each key dynamically.
var result map[string]interface{}
// Unmarshal returns nil error only if syntax is correct.
// It also validates that the top level is an object.
if err := json.Unmarshal(data, &result); err != nil {
// Handle syntax errors or type mismatches immediately.
fmt.Println("Invalid JSON:", err)
return
}
// If we reach here, the JSON is valid.
fmt.Println("Valid JSON:", result)
}
The decoder treats all JSON numbers as float64 when targeting interface{}. This is a design choice to handle JSON's lack of integer types. It means big integers might lose precision. If you need exact integers, don't use interface{}. Use a struct or json.Number.
The decoder also treats JSON booleans as bool, strings as string, and null as nil. Arrays become []interface{}. Objects become map[string]interface{}. The decoder allocates these values on the heap. This is convenient for inspection but expensive if you're processing millions of records.
Validation is decoding. If it decodes, it's valid.
Structs enforce types automatically
Real code rarely uses map[string]interface{}. You define a struct to represent your data. The struct acts as the schema. The decoder validates types automatically against the struct definition. If the JSON has a string where the struct expects an integer, the decoder rejects it.
Here's how you validate a user profile. The struct fields define the allowed types and keys.
package main
import (
"encoding/json"
"fmt"
)
// UserProfile represents the expected shape of the JSON.
// Exported fields are required for the decoder to access them.
type UserProfile struct {
// json tags map struct fields to JSON keys.
// The decoder uses these tags to match fields.
Name string `json:"name"`
Age int `json:"age"`
// omitempty means the field is omitted if zero-valued during marshaling.
// It has no effect on unmarshaling.
Email string `json:"email,omitempty"`
}
func main() {
// Valid JSON matching the struct.
validData := []byte(`{"name": "Bob", "age": 30, "email": "bob@example.com"}`)
var user UserProfile
// Unmarshal validates types automatically against the struct definition.
// If "age" is a string, this returns an error.
if err := json.Unmarshal(validData, &user); err != nil {
fmt.Println("Validation failed:", err)
return
}
fmt.Printf("Valid user: %+v\n", user)
}
The decoder matches JSON keys to struct fields using the json tags. If no tag exists, it falls back to the field name, case-insensitive. Tags take priority. This gives you control over the mapping.
If the JSON contains a type mismatch, the decoder returns a *json.UnmarshalTypeError. The error message tells you exactly what went wrong. You might see json: cannot unmarshal string into Go struct field UserProfile.age of type int. This error includes the field name and the expected type. It makes debugging fast.
The decoder also handles missing fields gracefully. If the JSON omits "age", the struct field stays at its zero value (0 for int). The decode succeeds. This is intentional. JSON is often partial. If you need to enforce required fields, check for zero values after decoding, or use a pointer type and check for nil.
Trust the struct. If the struct doesn't fit, the JSON is wrong.
Catching typos with DisallowUnknownFields
By default, json.Unmarshal ignores keys that don't match any struct field. This is a silent failure mode. A typo in the JSON payload like "user_name" instead of "name" drops the data. Your struct field stays zero. You think the user has no name. They do. You just dropped it.
This behavior is dangerous for external APIs. Clients make typos. Libraries evolve. You want to know when the JSON contains keys you don't expect. Use json.Decoder with DisallowUnknownFields to turn silent drops into hard errors.
Here's how you enforce strict validation. The decoder rejects any key that doesn't map to a struct field.
package main
import (
"encoding/json"
"fmt"
"strings"
)
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}
func main() {
// JSON contains a typo: "prot" instead of "port".
// It also has an unexpected key "debug".
strictData := []byte(`{"host": "localhost", "prot": 8080, "debug": true}`)
// Decoder allows configuration of validation behavior.
// It reads from an io.Reader, which works with strings.NewReader.
decoder := json.NewDecoder(strings.NewReader(string(strictData)))
// DisallowUnknownFields makes the decoder reject any key that doesn't match.
// This catches typos and unexpected schema changes.
decoder.DisallowUnknownFields()
var config Config
// Unmarshal now fails on unknown fields.
if err := decoder.Decode(&config); err != nil {
// The error message identifies the unknown field.
fmt.Println("Strict validation failed:", err)
return
}
fmt.Printf("Valid config: %+v\n", config)
}
The error message points to the problem. You'll see json: unknown field "prot". This tells you exactly which key caused the rejection. You can fix the client or update the struct.
DisallowUnknownFields is essential for configuration files and API contracts. It prevents silent data loss. It forces you to keep your schema in sync with the data.
Use DisallowUnknownFields when processing untrusted input. Use default behavior when you expect partial updates or extension fields.
DisallowUnknownFields is your safety net.
Pitfalls and precision traps
JSON validation has edge cases. The decoder handles most of them, but you need to know where the traps are.
Floating point precision is the biggest trap. JSON has no integer type. All numbers are floating point. When you unmarshal into interface{}, the decoder uses float64. A 64-bit integer like 9007199254740993 loses precision. It becomes 9007199254740992. The value changes silently.
If you need exact integers, unmarshal into a struct with int64 fields. The decoder handles the conversion correctly. Or use json.Number to preserve the raw string representation. json.Number lets you parse the integer manually with strconv.ParseInt.
Another trap is nil handling. If the JSON contains null for a field, the decoder sets the Go value to nil or zero. If the field is a pointer, it becomes nil. If the field is a value type, it becomes the zero value. This can cause panics if you dereference a nil pointer later. Check for nil after decoding if the field might be null.
The decoder also has a size limit. By default, it limits the depth of nesting to prevent stack overflows. Malicious JSON with extreme nesting can trigger this limit. The error is json: cannot unmarshal with max nesting depth exceeded. This is a defense against denial-of-service attacks. You usually don't need to adjust this limit.
Error handling follows Go conventions. Check the error immediately. Return or log it. Don't ignore it. The if err != nil pattern is verbose by design. It makes the unhappy path visible. The community accepts the boilerplate because it prevents silent failures.
The worst validation bug is the one that never logs.
Partial validation with RawMessage
Sometimes you need to validate the structure but defer parsing a specific field. This happens with polymorphic JSON. You have a wrapper object with a type discriminator. The inner data depends on the type. You want to validate the wrapper, check the type, then unmarshal the inner data into the right struct.
json.RawMessage is a []byte that delays unmarshaling. You can unmarshal the outer structure, inspect the discriminator, then unmarshal the raw bytes into the target type. This validates the wrapper without committing to the inner structure yet.
Here's how you handle polymorphic data. The Data field stays as raw bytes until you know the type.
package main
import (
"encoding/json"
"fmt"
)
// EventWrapper holds the common fields and raw payload.
// json.RawMessage delays parsing of the "data" field.
type EventWrapper struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
type ClickEvent struct {
Button string `json:"button"`
}
type PurchaseEvent struct {
Amount float64 `json:"amount"`
}
func main() {
// Polymorphic JSON with a type discriminator.
payload := []byte(`{"type": "click", "data": {"button": "submit"}}`)
var wrapper EventWrapper
// Unmarshal validates the wrapper structure.
// The "data" field remains as raw bytes.
if err := json.Unmarshal(payload, &wrapper); err != nil {
fmt.Println("Wrapper validation failed:", err)
return
}
// Now validate the inner data based on the type.
switch wrapper.Type {
case "click":
var click ClickEvent
// Unmarshal the raw bytes into the specific type.
if err := json.Unmarshal(wrapper.Data, &click); err != nil {
fmt.Println("Click data validation failed:", err)
return
}
fmt.Printf("Click event: %+v\n", click)
case "purchase":
var purchase PurchaseEvent
if err := json.Unmarshal(wrapper.Data, &purchase); err != nil {
fmt.Println("Purchase data validation failed:", err)
return
}
fmt.Printf("Purchase event: %+v\n", purchase)
default:
fmt.Println("Unknown event type")
}
}
This pattern lets you validate incrementally. You check the wrapper first. If the wrapper is invalid, you fail fast. If the wrapper is valid, you inspect the type. Then you validate the inner data against the correct schema. This avoids allocating the wrong struct types. It also gives precise error messages for the inner data.
json.RawMessage is useful for large payloads too. You can validate the headers, check if the payload is relevant, then skip parsing if it's not. This saves memory and CPU.
When to use what
Go gives you tools for different validation needs. Pick the right tool based on your constraints.
Use map[string]interface{} when you need to inspect arbitrary JSON without a schema. Use this for debugging, logging, or generic proxies where the shape varies per request.
Use a struct with json.Unmarshal when you have a known shape and need type safety. Use this for API handlers, configuration parsing, and data models. The struct enforces types and keys automatically.
Use json.Decoder with DisallowUnknownFields when you are processing untrusted input and want to reject unexpected keys. Use this for configuration files and strict API contracts where typos must fail loudly.
Use json.RawMessage when you need to validate a wrapper before parsing the payload. Use this for polymorphic JSON, event streams, or large documents where you might skip parsing based on headers.
Use json.Number when you need to preserve large integer precision. Use this for financial data, IDs, or any numeric field that exceeds float64 precision.
Validation is a boundary check. Define the boundary clearly. Let the decoder enforce it.