Request validation in Go: a hands-on guide for engineers
Invalid input is one of the most common sources of bugs in production APIs. We've all debugged issues where a negative quantity slipped through, or a malformed email address caused silent failures downstream. Request validation in Go: a hands-on guide for engineers covers practical patterns we use to catch these problems at the boundary, before they corrupt your data or crash your service.
In this article, we'll walk through a battle-tested approach using the go-playground/validator library, structured error responses, and clear separation of concerns. You'll see how to validate input before using it, return meaningful 400 errors to clients, and keep your codebase maintainable as validation rules grow.
Using go-playground/validator with struct tags
The go-playground/validator package provides a declarative way to attach validation rules directly to your structs. Rather than scattering validation logic across your handlers, you define constraints as tags and invoke a single validator instance. This keeps validation rules visible and easy to audit.
Let's say we're building an e-commerce order API. A typical create-order request looks like this:
package order
import "github.com/go-playground/validator/v10"
type CreateOrderRequest struct {
CustomerEmail string `json:"customer_email" validate:"required,email"`
Items []OrderItem `json:"items" validate:"required,min=1,dive"`
ShippingZip string `json:"shipping_zip" validate:"required,len=5"`
Quantity int `json:"quantity" validate:"required,gt=0"`
}
type OrderItem struct {
SKU string `json:"sku" validate:"required,len=8"`
Quantity int `json:"quantity" validate:"required,gt=0"`
}
var validate *validator.Validator
func init() {
validate = validator.New()
}
The tags like required, email, gt=0, and dive are built-in validators. The dive directive tells the validator to recursively check each item in the Items slice. When you need domain-specific rules—like checking if a SKU exists in your catalog—you can register custom validators:
func init() {
validate = validator.New()
validate.RegisterValidationFunc("validsku", func(fl validator.FieldLevel) bool {
sku := fl.Field().String()
// Check catalog, database, cache, or whatever
return isValidSKU(sku)
})
}
type CreateOrderRequest struct {
Items []OrderItem `json:"items" validate:"required,min=1,dive"`
}
type OrderItem struct {
SKU string `json:"sku" validate:"required,validsku"`
Quantity int `json:"quantity" validate:"required,gt=0"`
}
Now, when you receive a request in your handler, you call validate.Struct(req). It returns nil if all rules pass, or a validator.ValidationErrors collection if any fail. The key advantage here is centralization: every request type has its validation rules defined in one place, making it easy for a new team member to understand what's expected.
Returning 400 with field errors
When validation fails, clients deserve a clear, structured error response. A bare HTTP 400 doesn't tell them what went wrong. We return a response body that lists each field that failed and why.
Here's a helper function to extract field-level error details:
package errorutil
import (
"github.com/go-playground/validator/v10"
)
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
type ValidationErrorResponse struct {
Code string `json:"code"`
Errors []FieldError `json:"errors"`
}
func NewValidationErrors(ve validator.ValidationErrors) ValidationErrorResponse {
fields := make([]FieldError, 0, len(ve))
for _, err := range ve {
fe := FieldError{
Field: err.Field(),
Message: messageForTag(err.Tag(), err.Param()),
}
fields = append(fields, fe)
}
return ValidationErrorResponse{
Code: "VALIDATION_ERROR",
Errors: fields,
}
}
func messageForTag(tag, param string) string {
switch tag {
case "required":
return "this field is required"
case "email":
return "must be a valid email address"
case "gt":
return "must be greater than " + param
case "min":
return "must have at least " + param + " items"
case "validsku":
return "SKU not found in catalog"
default:
return "validation failed"
}
}
In your HTTP handler, you'd use it like this:
package handlers
import (
"encoding/json"
"net/http"
"github.com/go-playground/validator/v10"
)
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if err := validate.Struct(req); err != nil {
ve := err.(validator.ValidationErrors)
respBody := errorutil.NewValidationErrors(ve)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(respBody)
return
}
// Process the valid request
order, err := h.service.CreateOrder(r.Context(), &req)
if err != nil {
// Handle service-level errors...
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(order)
}
A client making a bad request now gets a response like:
{
"code": "VALIDATION_ERROR",
"errors": [
{
"field": "customer_email",
"message": "must be a valid email address"
},
{
"field": "Items[0].Quantity",
"message": "must be greater than 0"
}
]
}
This clarity reduces back-and-forth debugging and makes integration testing straightforward for client teams.
Validate in one place per request type
As your API grows, you'll handle dozens of request types: create orders, update orders, cancel orders, refund orders. Each has different validation rules. The worst mistake is scattering validation logic—some in handlers, some in services, some in helper functions. Six months later, nobody knows where to look when a rule needs to change.
Our pattern is simple: each request struct is the single source of truth for its validation rules. The handler receives it, validates it once, and passes it to the service layer. The service layer trusts the request is already valid.
package order
// CancelOrderRequest is the only place where cancel validation lives
type CancelOrderRequest struct {
OrderID string `json:"order_id" validate:"required,uuid4"`
Reason string `json:"reason" validate:"required,max=200"`
}
// RefundOrderRequest has different rules
type RefundOrderRequest struct {
OrderID string `json:"order_id" validate:"required,uuid4"`
Amount int `json:"amount_cents" validate:"required,gt=0"`
ReasonCode string `json:"reason_code" validate:"required,oneof=DEFECTIVE WRONG_ITEM DAMAGED"`
}
In your handler layer, you always do the same thing: decode, validate, then call the service. This consistency makes the codebase predictable. New team members don't have to hunt for validation logic; it's always in the request struct.
If you have middleware that applies to multiple endpoints (like authentication or rate limiting), keep that separate from request validation. Validation is about the shape and content of the request body; cross-cutting concerns belong in middleware.
Business rules belong in services
There's an important distinction between format validation and business rule validation. Go-playground/validator excels at format validation: is this an email, is this a positive integer, does this string have the right length. But business rules—like "can we cancel an order that's already shipped" or "can this user access this resource"—require context and database lookups. Those belong in your service layer.
Here's how we separate them:
package order
import "context"
type Service struct {
repo Repository
}
// Handler does format validation only
func (h *OrderHandler) CancelOrder(w http.ResponseWriter, r *http.Request) {
var req CancelOrderRequest
json.NewDecoder(r.Body).Decode(&req)
if err := validate.Struct(req); err != nil {
// Return 400
return
}
// Service does business rule validation
if err := h.service.CancelOrder(r.Context(), req); err != nil {
// Handle service errors (422, 404, etc)
}
}
// Service enforces business rules
func (s *Service) CancelOrder(ctx context.Context, req CancelOrderRequest) error {
order, err := s.repo.GetOrder(ctx, req.OrderID)
if err != nil {
return fmt.Errorf("order not found")
}
if order.Status == "shipped" {
return fmt.Errorf("cannot cancel shipped orders")
}
if order.Status == "delivered" {
return fmt.Errorf("cannot cancel delivered orders")
}
return s.repo.UpdateOrderStatus(ctx, req.OrderID, "cancelled")
}
Notice the HTTP status codes differ: format validation failures return 400 (client error in the request itself), but business rule violations return 422 (semantically invalid, even if the JSON is well-formed). This distinction helps clients understand what went wrong.
Common mistakes
Validating too much in the handler. Handlers should only orchestrate: decode, validate format, call service, encode response. If you're checking database state in the handler, move that logic to the service layer. It keeps handlers thin and services testable.
Reusing request structs across multiple endpoints. A create request and an update request have different rules. Even if they share most fields, define separate structs with their own validation tags. The small amount of duplication is worth the clarity.
Not versioning your validator instance. Create your validator.Validator once at startup and reuse it. Creating a new instance per request is wasteful. Store it as a package variable or embed it in a handler struct.
Ignoring JSON unmarshaling errors. Format validation catches wrong types, but if your JSON payload is malformed, the decoder will fail before validation runs. Always handle decode errors separately and return 400.
Writing custom validators that do I/O. Custom validators like our validsku example above should be fast. If you're making database queries or calling external services, do that in the service layer instead. Keep validators pure.
Key takeaways
- Use go-playground/validator with struct tags to centralize format validation rules; each request type defines its own validation in one place.
- Return 400 with a structured error response listing each field that failed and a human-readable message explaining why.
- Keep handler validation to format checks only; move business rule validation to the service layer where you have database access and context.
- Distinguish between 400 (format validation failed) and 422 (request is well-formed but semantically invalid for business reasons).
- Create your validator instance once and reuse it; custom validators should be lightweight and fast.
- Never scatter validation logic across handlers, services, and helpers; pick one pattern and stick to it across your team's codebase.






Comments
Loading comments…