How do you handle request validation and structured error responses in a Go API?
Quick Answer
Validate incoming data explicitly after decoding it (checking required fields, ranges, formats) rather than relying on JSON decoding alone, since encoding/json only checks that the JSON is well-formed and roughly type-compatible, not that business rules are satisfied — libraries like go-playground/validator add struct-tag-driven validation (validate:"required,email") if you want to avoid writing every check by hand. For error responses, define a consistent JSON error shape (commonly {"error": "message", "code": "VALIDATION_FAILED"}) and a small helper that writes it with the correct HTTP status code, so every endpoint in the API returns errors in the same predictable structure instead of ad hoc plain-text messages that client code has to special-case per endpoint.
Detailed Answer
JSON decoding alone only checks that the payload is syntactically valid JSON roughly matching your struct's shape. It says nothing about whether the actual values make sense for your business logic.
Why decoding alone isn't validation
type CreateUserRequest struct {
Email string `json:"email"`
Age int `json:"age"`
}
var req CreateUserRequest
json.NewDecoder(r.Body).Decode(&req)
// req.Email could be "", or "not-an-email"
// req.Age could be -5, or 200
// Decode succeeded either way — it never checked any of this
Manual validation
func validateCreateUser(req CreateUserRequest) []string {
var errs []string
if req.Email == "" {
errs = append(errs, "email is required")
} else if !strings.Contains(req.Email, "@") {
errs = append(errs, "email is invalid")
}
if req.Age < 0 || req.Age > 150 {
errs = append(errs, "age must be between 0 and 150")
}
return errs
}
Struct-tag-driven validation with a library
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=0,lte=150"`
}
validate := validator.New()
if err := validate.Struct(req); err != nil {
// err contains details on every failed validation rule
}
This avoids hand-writing repetitive checks for common rules (required, email format, numeric ranges), while still supporting custom validation functions for anything the built-in tags don't cover.
A consistent error response shape
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code"`
}
func writeError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(ErrorResponse{Error: message, Code: code})
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_JSON", "request body is not valid JSON")
return
}
if errs := validateCreateUser(req); len(errs) > 0 {
writeError(w, http.StatusBadRequest, "VALIDATION_FAILED", strings.Join(errs, "; "))
return
}
// ... proceed ...
}
Why consistency matters here specifically
Client code integrating with your API benefits enormously from every error looking the same shape, with a stable machine-readable code field client code can branch on, rather than having to parse different plain-text error strings per endpoint to figure out what actually went wrong.