Skip to main content

Data Validation and Transformation in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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 ValidationErrors as a map[string]string for structured field-level errors that clients can display alongside forms
  • go-playground/validator handles 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

Resources

Comments

👍 Was this article helpful?