Validation ensures data meets your business rules before it enters your system. The wrong approach — scattered if checks throughout business logic — makes validation rules hard to find, duplicate, and test. The right approach: validate at the entry point (API handler, file parser, queue consumer), collect all errors at once, and either reject or transform the data before passing it deeper.
Go has two complementary approaches: manual validation (full control, no dependencies) and github.com/go-playground/validator (declarative struct tags, battle-tested rules). Both have their place.
For HTTP-specific validation with binding see Go Gin framework. For error types see Go custom errors.
Manual Validation: Collect All Errors
The most important property of a good validator: collect all errors before returning, not just the first one. Users should see all problems at once, not fix one at a time:
type ValidationErrors map[string]string
func (ve ValidationErrors) Error() string {
msgs := make([]string, 0, len(ve))
for field, msg := range ve {
msgs = append(msgs, field+": "+msg)
}
sort.Strings(msgs)
return strings.Join(msgs, "; ")
}
type CreateUserRequest struct {
Name string
Email string
Age int
Password string
}
func validateCreateUser(r CreateUserRequest) ValidationErrors {
errs := make(ValidationErrors)
if strings.TrimSpace(r.Name) == "" {
errs["name"] = "required"
} else if len(r.Name) > 100 {
errs["name"] = "too long (max 100 characters)"
}
if r.Email == "" {
errs["email"] = "required"
} else if !strings.Contains(r.Email, "@") || !strings.Contains(r.Email, ".") {
errs["email"] = "invalid format"
}
if r.Age < 0 || r.Age > 150 {
errs["age"] = fmt.Sprintf("must be 0–150, got %d", r.Age)
}
if len(r.Password) < 8 {
errs["password"] = "must be at least 8 characters"
}
if len(errs) == 0 {
return nil
}
return errs
}
// Usage in HTTP handler
func createUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
json.NewDecoder(r.Body).Decode(&req)
if errs := validateCreateUser(req); errs != nil {
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]any{"errors": errs})
return
}
// proceed with valid data
}
Returning ValidationErrors as a map lets clients display field-level error messages alongside their input forms. The Error() string method makes it usable as a regular error for logging.
Struct Validation with go-playground/validator
For larger applications with many structs, declarative validation tags reduce boilerplate:
go get github.com/go-playground/validator/v10
import "github.com/go-playground/validator/v10"
type Address struct {
Street string `validate:"required"`
City string `validate:"required,min=2,max=100"`
Country string `validate:"required,len=2"` // ISO country code
Zip string `validate:"required,alphanum"`
}
type Order struct {
CustomerID string `validate:"required,uuid4"`
Items []Item `validate:"required,min=1,dive"` // dive validates each element
Total float64 `validate:"required,gt=0"`
Address Address `validate:"required"`
Email string `validate:"required,email"`
CreatedAt time.Time `validate:"required"`
}
var validate = validator.New()
func validateOrder(o Order) error {
if err := validate.Struct(o); err != nil {
// Convert to our field-map format
errs := make(ValidationErrors)
for _, ve := range err.(validator.ValidationErrors) {
errs[ve.Field()] = humanizeValidationError(ve)
}
return errs
}
return nil
}
func humanizeValidationError(ve validator.FieldError) string {
switch ve.Tag() {
case "required": return "required"
case "email": return "invalid email format"
case "min": return fmt.Sprintf("must be at least %s", ve.Param())
case "max": return fmt.Sprintf("must be at most %s", ve.Param())
case "gt": return fmt.Sprintf("must be greater than %s", ve.Param())
case "uuid4": return "must be a valid UUID"
default: return fmt.Sprintf("invalid (%s)", ve.Tag())
}
}
Common validator tags: required, email, url, min, max, len, gt, lt, gte, lte, oneof=a b c, uuid4, alphanum, alpha, numeric, dive (for slice/map elements).
Custom Validation Rules
Register custom validators for domain-specific rules:
validate.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
slug := fl.Field().String()
matched, _ := regexp.MatchString(`^[a-z0-9-]+$`, slug)
return matched && len(slug) >= 3 && len(slug) <= 63
})
type Product struct {
Slug string `validate:"required,slug"`
Price float64 `validate:"required,gt=0"`
}
Cross-Field Validation
Validate relationships between fields using struct-level validators:
validate.RegisterStructValidation(func(sl validator.StructLevel) {
event := sl.Current().Interface().(Event)
if event.EndTime.Before(event.StartTime) {
sl.ReportError(event.EndTime, "EndTime", "end_time", "gtfield", "StartTime")
}
}, Event{})
type Event struct {
Name string `validate:"required"`
StartTime time.Time `validate:"required"`
EndTime time.Time `validate:"required"`
}
Input Sanitization
Validation tells you what’s wrong; sanitization cleans it before use. These are complementary, not alternatives:
// Sanitize trims whitespace and normalizes strings before validation
func sanitizeCreateUser(r *CreateUserRequest) {
r.Name = strings.TrimSpace(r.Name)
r.Email = strings.ToLower(strings.TrimSpace(r.Email))
// Note: never trim or modify passwords
}
// Safe to HTML-display (prevents XSS if output goes to HTML without escaping)
func sanitizeComment(text string) string {
text = strings.TrimSpace(text)
text = strings.ReplaceAll(text, "\x00", "") // remove null bytes
// For HTML: html.EscapeString(text) — or better, use template/html for rendering
return text
}
The order: sanitize first, then validate. Trimming whitespace before checking required avoids the frustrating case where a user types " " and gets a confusing error.
Transformation Pipelines
For ETL workloads — reading CSV, normalizing data, and writing to a database — a pipeline of transformation stages processes records cleanly:
type Record map[string]any
type Transform func(Record) (Record, error)
// Pipeline applies transforms in sequence, stopping on first error
func Pipeline(transforms ...Transform) Transform {
return func(r Record) (Record, error) {
for _, t := range transforms {
var err error
r, err = t(r)
if err != nil {
return nil, err
}
}
return r, nil
}
}
// Reusable transforms
func TrimStrings(r Record) (Record, error) {
for k, v := range r {
if s, ok := v.(string); ok {
r[k] = strings.TrimSpace(s)
}
}
return r, nil
}
func NormalizeEmail(r Record) (Record, error) {
if email, ok := r["email"].(string); ok {
r["email"] = strings.ToLower(email)
}
return r, nil
}
func RequireFields(fields ...string) Transform {
return func(r Record) (Record, error) {
for _, f := range fields {
if v, ok := r[f]; !ok || v == "" {
return nil, fmt.Errorf("missing required field: %s", f)
}
}
return r, nil
}
}
// Build a pipeline for user CSV import
userPipeline := Pipeline(
TrimStrings,
NormalizeEmail,
RequireFields("name", "email"),
)
// Process records
for _, raw := range csvRows {
clean, err := userPipeline(raw)
if err != nil {
log.Printf("skip row: %v", err)
continue
}
db.InsertUser(clean)
}
This pipeline model scales well — adding a new step is adding a new Transform function to the pipeline. Each transform is pure (input → output) and independently testable.
Deciding When to Fail Fast vs Collect All Errors
Fail fast when:
- Processing a stream of records independently (one bad record doesn’t affect others — log and skip)
- Validating a configuration file at startup (stop immediately, force fix before proceeding)
- A later transform depends on an earlier one succeeding
Collect all errors when:
- Validating user input from a form or API request (show all problems at once)
- Batch importing data where you want a full report of what failed before retrying
Summary
- Collect all validation errors before returning — users shouldn’t fix one error at a time
- Use
ValidationErrorsas amap[string]stringfor structured field-level errors that clients can display alongside forms go-playground/validatorhandles common rules declaratively; write custom validators for domain rules- Sanitize before validating — trim whitespace, normalize case, remove null bytes
- Transformation pipelines compose reusable steps cleanly for ETL workloads
- Fail fast for streams/config; collect all errors for user-facing validation
Comments