The struct is your contract
You build an API endpoint to create a user. A client sends {"name": "", "age": -5}. Your code saves it. The database accepts it. The UI displays "User -5". The bug report lands on your desk. Go will not stop you from saving garbage data. The language trusts you to define what valid means. Your struct fields are just memory slots until you enforce rules.
In Go, the request body usually becomes a struct. The struct tells the decoder how to map JSON keys to fields. It also gives you a place to attach validation logic. You don't validate a raw byte slice. You validate a typed object. This keeps your handler clean and your data safe. The struct acts as a contract between the network and your business logic. If the data doesn't fit the contract, you reject it before it touches your database or cache.
Decoding safely
The first line of defense is the decoder. Go provides encoding/json with two main ways to parse data: json.Unmarshal and json.NewDecoder. They look similar but behave differently under load. Unmarshal reads the entire body into a byte slice before parsing. If a client sends a 10-gigabyte payload, your server allocates 10 gigabytes of memory and crashes. NewDecoder reads incrementally from the stream. It processes chunks as they arrive. It uses constant memory regardless of payload size.
Always wrap the request body with http.MaxBytesReader. This helper limits how many bytes the decoder can read. It protects against slowloris attacks and accidental memory exhaustion. The limit applies to the decoded stream, not just the header.
Here's the standard pattern for a safe handler. The function accepts a context as the first parameter, following Go convention. The receiver name for methods matches the type with one or two letters.
// CreateUser handles POST /users.
func CreateUser(ctx context.Context, w http.ResponseWriter, r *http.Request) {
// Limit body size to 1MB to prevent memory exhaustion.
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req CreateUserRequest
// Decode streams the body and stops on the first syntax error.
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Return 400 for malformed JSON or size limit exceeded.
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Validate business rules after decoding.
if req.Name == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
// Process the request using the context for cancellation.
if err := saveUser(ctx, req); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
The decoder catches syntax errors like missing commas or trailing commas. It also catches type mismatches. If the JSON has a number where you expect a string, Decode returns an error. You catch semantic errors like empty names or negative ages. The decoder is a stream. Protect the stream with a limit.
Zero values and the pointer trick
Go structs initialize fields to zero values. An int is 0. A string is "". A bool is false. This causes ambiguity in APIs. If your API allows a user to be age 0, you can't distinguish between "client sent 0" and "client forgot the field". Both result in req.Age == 0.
Use pointers for optional fields. A *int is nil when the client omits the field. It points to a value when the client sends something, even if that value is 0. This removes ambiguity. The trade-off is nil checks. You must verify the pointer is not nil before dereferencing it. Dereferencing a nil pointer panics at runtime.
Struct tags control how the JSON encoder and decoder map keys. The json tag sets the key name. The omitempty option tells the encoder to skip the field if it holds the zero value. For pointers, the zero value is nil, so omitempty skips the field when the pointer is nil.
type CreateUserRequest struct {
// Name is required. Empty string means the field was not sent or is invalid.
Name string `json:"name"`
// Age is optional. Pointer is nil if the client omits the field.
Age *int `json:"age,omitempty"`
// Active is optional. Defaults to true if omitted, false if sent as false.
Active *bool `json:"active,omitempty"`
}
// Validate checks business rules on the request.
func (c CreateUserRequest) Validate() []error {
var errs []error
if c.Name == "" {
errs = append(errs, fmt.Errorf("name is required"))
}
if c.Age != nil {
age := *c.Age
if age < 0 || age > 150 {
errs = append(errs, fmt.Errorf("age must be between 0 and 150"))
}
}
return errs
}
The receiver name c matches the type CreateUserRequest. This follows the convention of short receiver names. The validation method returns a slice of errors. This allows the caller to collect all issues at once. Pointers solve ambiguity. They also add nil checks. Pick the burden you prefer.
Collecting errors for better UX
Returning on the first error is easy to code. It creates a frustrating experience for the client. The client fixes "name", resends, gets "email". Fixes "email", gets "age". This retry loop wastes time and network bandwidth.
Collect errors in a slice and return them all. The client sees every problem in one response. They can fix everything and retry once. This pattern scales well. As your API grows, validation logic accumulates. A validation method on the struct keeps the handler clean. The handler calls req.Validate(), checks the slice, and returns early if there are errors.
The community accepts verbose error handling. The if err != nil pattern makes the unhappy path visible. Don't hide errors. Return them explicitly. When you collect errors, you might want to join them into a single message or return a structured JSON response with a list of error strings.
// Validate returns all errors found in the request.
func (c CreateUserRequest) Validate() []error {
var errs []error
if c.Name == "" {
errs = append(errs, fmt.Errorf("name is required"))
}
if c.Email == "" {
errs = append(errs, fmt.Errorf("email is required"))
} else if !strings.Contains(c.Email, "@") {
errs = append(errs, fmt.Errorf("email must contain @"))
}
return errs
}
// In the handler:
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
// ... decoding logic ...
if errs := req.Validate(); len(errs) > 0 {
// Join errors with newlines for a simple text response.
msg := strings.Join(
func() []string {
var msgs []string
for _, err := range errs {
msgs = append(msgs, err.Error())
}
return msgs
}(),
"\n",
)
http.Error(w, msg, http.StatusBadRequest)
return
}
// ... processing logic ...
}
One error forces a retry loop. All errors respect the user's time.
Pitfalls and compiler traps
Go's compiler catches many mistakes, but validation bugs often hide at runtime. If you pass a non-pointer to json.Unmarshal, the compiler rejects the program with json: Unmarshal(non-pointer .... This is a compile-time safety net. At runtime, you can still panic if you dereference a nil pointer or index out of bounds.
Overflow attacks are a real risk. If you decode a JSON number into an int, and the client sends a number larger than math.MaxInt64, the decoder returns an error. You must handle this error. If you ignore it, the value might wrap or fail silently depending on the decoder settings. Always check the error from Decode.
Large numbers in JSON are strings by default in some languages. Go decodes numbers into float64 by default. This loses precision for large integers. If you need exact precision, use json.Decoder.UseNumber(). This decodes numbers into json.Number, which is a string. You can parse the string into a big.Int or int64 manually.
// DecodeWithPrecision handles large numbers safely.
func DecodeWithPrecision(r *http.Request) (map[string]interface{}, error) {
r.Body = http.MaxBytesReader(nil, r.Body, 1<<20)
var result map[string]interface{}
dec := json.NewDecoder(r.Body)
// UseNumber prevents float64 precision loss for large integers.
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, err
}
return result, nil
}
The decoder is a stream. Protect the stream with a limit. Trust the decoder for format. Trust your code for limits.
When to use a library
Manual validation works for small projects. When validation grows, struct tags help. The go-playground/validator library is the most popular choice. It uses struct tags to define rules. You can check required fields, length, regex patterns, and cross-field dependencies.
The library moves validation rules into the struct definition. This centralizes rules. It also hides logic from the type signature. The struct looks like data, but it carries behavior. This is a trade-off. Some teams prefer explicit validation functions to keep structs pure. Others prefer tags for brevity.
import "github.com/go-playground/validator/v10"
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2"`
Email string `json:"email" validate:"required,email"`
}
// Validate uses the validator library to check tags.
func (c CreateUserRequest) Validate() error {
validate := validator.New()
return validate.Struct(c)
}
The validator returns a single error that may contain multiple field violations. You can parse the error to extract individual messages. The library handles edge cases like unicode length and email format. It saves you from writing regex patterns manually. Tags centralize rules. They hide logic. Know the trade-off.
Decision matrix
Use manual validation when your rules are simple and you want zero dependencies. Use json.NewDecoder with http.MaxBytesReader when handling untrusted input to prevent memory exhaustion. Use pointers for optional fields when the zero value is a valid input. Use a validation library when your struct has many fields and complex cross-field rules. Use error collection when the client needs to fix multiple issues in a single request. Use json.Unmarshal only when you already have the full body in a byte slice and trust the source.