Go data validation is a critical aspect of building reliable and secure applications. I’ve implemented validation techniques in numerous projects and found that proper validation is often the difference between a stable application and one plagued with bugs and security vulnerabilities. Let me share practical approaches to validation in Go that will help you create more robust applications.
Understanding Data Validation in Go
Data validation ensures that information entering your system meets your expectations. This process guards against corrupted data, security exploits, and logical errors in your application.
Go’s static typing provides basic validation, but we need more sophisticated approaches for complex real-world applications. Effective validation strategies combine multiple techniques to create defense layers.
Struct Tag Validation
Struct tags provide a declarative approach to validation that’s both readable and maintainable. The widely-used validator package by go-playground leverages this approach:
package main
import (
"fmt"
"github.com/go-playground/validator/v10"
)
type User struct {
Username string `validate:"required,alphanum,min=4,max=20"`
Email string `validate:"required,email"`
Age int `validate:"gte=18,lte=120"`
Password string `validate:"required,min=8"`
Role string `validate:"oneof=admin user guest"`
}
func main() {
validate := validator.New()
validUser := User{
Username: "johndoe",
Email: "[email protected]",
Age: 25,
Password: "secure_password",
Role: "user",
}
invalidUser := User{
Username: "j",
Email: "not-an-email",
Age: 15,
Password: "short",
Role: "superuser",
}
err := validate.Struct(validUser)
fmt.Println("Valid user:", err == nil)
err = validate.Struct(invalidUser)
fmt.Println("Invalid user:", err)
}
This approach centralizes validation rules within your data structures. I’ve found this particularly useful in larger projects where validation logic needs to be consistent across different parts of the application.
Custom Validation Functions
Sometimes tag-based validation isn’t sufficient for complex business rules. Custom validation functions allow you to implement any validation logic:
package main
import (
"errors"
"fmt"
"regexp"
"strings"
)
type Payment struct {
Amount float64
Currency string
CardNumber string
ExpirationMonth int
ExpirationYear int
CVV string
}
func (p Payment) Validate() error {
var errMsgs []string
// Amount validation
if p.Amount <= 0 {
errMsgs = append(errMsgs, "amount must be positive")
}
// Currency validation
validCurrencies := map[string]bool{"USD": true, "EUR": true, "GBP": true}
if !validCurrencies[p.Currency] {
errMsgs = append(errMsgs, "currency must be USD, EUR, or GBP")
}
// Card number validation (simplified)
re := regexp.MustCompile(`^\d{16}$`)
if !re.MatchString(p.CardNumber) {
errMsgs = append(errMsgs, "card number must be 16 digits")
}
// Expiration date validation
if p.ExpirationMonth < 1 || p.ExpirationMonth > 12 {
errMsgs = append(errMsgs, "expiration month must be between 1 and 12")
}
currentYear := 2023 // In practice, get the current year dynamically
if p.ExpirationYear < currentYear || p.ExpirationYear > currentYear+10 {
errMsgs = append(errMsgs, fmt.Sprintf("expiration year must be between %d and %d", currentYear, currentYear+10))
}
// CVV validation
if len(p.CVV) < 3 || len(p.CVV) > 4 {
errMsgs = append(errMsgs, "CVV must be 3 or 4 digits")
}
if len(errMsgs) > 0 {
return errors.New(strings.Join(errMsgs, "; "))
}
return nil
}
func main() {
payment := Payment{
Amount: -50.0,
Currency: "CAD",
CardNumber: "12345",
ExpirationMonth: 13,
ExpirationYear: 2020,
CVV: "12345",
}
if err := payment.Validate(); err != nil {
fmt.Println("Validation errors:", err)
}
}
I’ve implemented similar validation in financial applications where ensuring the correctness of payment details is crucial. Custom functions give you complete control over validation logic.
Request Middleware Validation
Validating HTTP requests early in the request lifecycle prevents invalid data from reaching your business logic:
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/go-playground/validator/v10"
)
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=2,max=50"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
Age int `json:"age" validate:"required,gte=18"`
}
var validate = validator.New()
func validateBody(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
next(w, r)
return
}
var request CreateUserRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&request); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if err := validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Store validated request in context
ctx := r.Context()
ctx = context.WithValue(ctx, "request", request)
next(w, r.WithContext(ctx))
}
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
// Retrieve validated request from context
req := r.Context().Value("request").(CreateUserRequest)
// Process the validated request
log.Printf("Creating user: %s (%s)", req.Name, req.Email)
w.WriteHeader(http.StatusCreated)
w.Write([]byte("User created successfully"))
}
func main() {
http.HandleFunc("/users", validateBody(createUserHandler))
log.Fatal(http.ListenAndServe(":8080", nil))
}
This middleware approach has saved me countless hours of debugging by catching invalid data before it affects the core logic of applications.
Input Sanitization
Validation alone isn’t enough; we also need to clean and standardize input data:
package main
import (
"html"
"regexp"
"strings"
)
// Sanitize HTML content to prevent XSS attacks
func sanitizeHTML(input string) string {
return html.EscapeString(input)
}
// Standardize email addresses
func sanitizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// Remove all non-alphanumeric characters
func sanitizeAlphanumeric(input string) string {
reg := regexp.MustCompile("[^a-zA-Z0-9]+")
return reg.ReplaceAllString(input, "")
}
// Sanitize phone numbers
func sanitizePhone(phone string) string {
reg := regexp.MustCompile("[^0-9]+")
return reg.ReplaceAllString(phone, "")
}
// Prevent SQL injection
func sanitizeForSQL(input string) string {
// This is a simplified example. In practice, always use prepared statements
return strings.ReplaceAll(strings.ReplaceAll(input, "'", "''"), ";", "")
}
type UserInput struct {
Name string
Email string
Phone string
Description string
}
func SanitizeUserInput(input *UserInput) {
input.Name = sanitizeAlphanumeric(input.Name)
input.Email = sanitizeEmail(input.Email)
input.Phone = sanitizePhone(input.Phone)
input.Description = sanitizeHTML(input.Description)
}
I make it a practice to sanitize all user input, especially when building applications that handle user-generated content.
Error Aggregation
Collecting all validation errors instead of stopping at the first one improves user experience:
package main
import (
"fmt"
"strings"
)
type ValidationError struct {
Field string
Message string
}
type ValidationErrors []ValidationError
func (ve ValidationErrors) Error() string {
var messages []string
for _, err := range ve {
messages = append(messages, fmt.Sprintf("%s: %s", err.Field, err.Message))
}
return strings.Join(messages, ", ")
}
func ValidateProduct(p Product) ValidationErrors {
var errors ValidationErrors
if p.Name == "" {
errors = append(errors, ValidationError{Field: "name", Message: "cannot be empty"})
} else if len(p.Name) < 3 {
errors = append(errors, ValidationError{Field: "name", Message: "must be at least 3 characters"})
}
if p.Price <= 0 {
errors = append(errors, ValidationError{Field: "price", Message: "must be greater than zero"})
}
if p.Quantity < 0 {
errors = append(errors, ValidationError{Field: "quantity", Message: "cannot be negative"})
}
if len(p.Categories) == 0 {
errors = append(errors, ValidationError{Field: "categories", Message: "at least one category is required"})
}
return errors
}
type Product struct {
Name string
Price float64
Quantity int
Categories []string
}
func main() {
p := Product{
Name: "A",
Price: -5.99,
Quantity: -10,
Categories: []string{},
}
if errs := ValidateProduct(p); len(errs) > 0 {
fmt.Println("Validation failed:", errs)
}
}
This approach gives users a comprehensive list of issues to fix, rather than forcing them to correct errors one by one.
Contextual Validation
Some validation rules depend on the relationships between different fields:
package main
import (
"errors"
"fmt"
"time"
)
type DateRange struct {
StartDate time.Time
EndDate time.Time
}
type Reservation struct {
Customer string
RoomType string
Dates DateRange
GuestCount int
MaxOccupancy int
BreakfastOnly bool
MealPlan string
}
func (r Reservation) Validate() error {
var errMsgs []string
// Basic field validation
if r.Customer == "" {
errMsgs = append(errMsgs, "customer name is required")
}
if r.RoomType == "" {
errMsgs = append(errMsgs, "room type is required")
}
// Contextual validation
// 1. Date range validation
now := time.Now()
if r.Dates.StartDate.Before(now) {
errMsgs = append(errMsgs, "start date cannot be in the past")
}
if r.Dates.EndDate.Before(r.Dates.StartDate) {
errMsgs = append(errMsgs, "end date must be after start date")
}
// 2. Guest count validation against capacity
if r.GuestCount <= 0 {
errMsgs = append(errMsgs, "guest count must be positive")
}
if r.GuestCount > r.MaxOccupancy {
errMsgs = append(errMsgs, fmt.Sprintf("guest count exceeds maximum occupancy of %d", r.MaxOccupancy))
}
// 3. Conflicting options validation
if r.BreakfastOnly && r.MealPlan != "" {
errMsgs = append(errMsgs, "cannot select both breakfast only and a meal plan")
}
if len(errMsgs) > 0 {
return errors.New(fmt.Sprintf("validation failed: %s", strings.Join(errMsgs, "; ")))
}
return nil
}
func main() {
res := Reservation{
Customer: "John Smith",
RoomType: "Deluxe",
Dates: DateRange{
StartDate: time.Now().AddDate(0, 0, -1), // Yesterday
EndDate: time.Now().AddDate(0, 0, -2), // Day before yesterday
},
GuestCount: 5,
MaxOccupancy: 4,
BreakfastOnly: true,
MealPlan: "Full Board",
}
if err := res.Validate(); err != nil {
fmt.Println(err)
}
}
Contextual validation has been essential in my work on booking systems where many rules depend on combinations of user choices.
Human-Readable Error Messages
Clear error messages guide users toward proper data formats:
package main
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
)
type ErrorResponse struct {
Field string `json:"field"`
Message string `json:"message"`
}
func translateValidationError(err error) []ErrorResponse {
var errors []ErrorResponse
validationErrors := err.(validator.ValidationErrors)
for _, e := range validationErrors {
var message string
switch e.Tag() {
case "required":
message = fmt.Sprintf("%s is required", e.Field())
case "email":
message = fmt.Sprintf("%s must be a valid email address", e.Field())
case "min":
message = fmt.Sprintf("%s must be at least %s characters long", e.Field(), e.Param())
case "max":
message = fmt.Sprintf("%s must be at most %s characters long", e.Field(), e.Param())
case "gte":
message = fmt.Sprintf("%s must be greater than or equal to %s", e.Field(), e.Param())
case "lte":
message = fmt.Sprintf("%s must be less than or equal to %s", e.Field(), e.Param())
case "oneof":
options := strings.Split(e.Param(), " ")
message = fmt.Sprintf("%s must be one of: %s", e.Field(), strings.Join(options, ", "))
default:
message = fmt.Sprintf("%s failed validation: %s", e.Field(), e.Tag())
}
errors = append(errors, ErrorResponse{
Field: strings.ToLower(e.Field()),
Message: message,
})
}
return errors
}
func main() {
validate := validator.New()
type User struct {
Username string `validate:"required,min=4,max=20"`
Email string `validate:"required,email"`
Age int `validate:"required,gte=18,lte=120"`
Role string `validate:"required,oneof=admin user guest"`
}
user := User{
Username: "a",
Email: "not-an-email",
Age: 15,
Role: "superuser",
}
err := validate.Struct(user)
if err != nil {
errors := translateValidationError(err)
for _, e := range errors {
fmt.Printf("%s: %s\n", e.Field, e.Message)
}
}
}
I’ve found that investing time in creating readable error messages dramatically reduces support requests and improves user satisfaction.
Combining Validation Techniques
In real-world applications, I typically combine multiple validation approaches:
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"github.com/go-playground/validator/v10"
)
var validate = validator.New()
// Custom validation function
func isValidUsername(fl validator.FieldLevel) bool {
username := fl.Field().String()
match, _ := regexp.MatchString("^[a-zA-Z0-9_]+$", username)
return match
}
type User struct {
Username string `json:"username" validate:"required,min=4,max=20,username"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8"`
Age int `json:"age" validate:"required,gte=18"`
Country string `json:"country" validate:"required"`
}
type ErrorResponse struct {
Field string `json:"field"`
Message string `json:"message"`
}
func formatValidationErrors(err error) []ErrorResponse {
var errors []ErrorResponse
validationErrors := err.(validator.ValidationErrors)
for _, e := range validationErrors {
var message string
switch e.Tag() {
case "required":
message = "This field is required"
case "email":
message = "Must be a valid email address"
case "min":
message = fmt.Sprintf("Must be at least %s characters long", e.Param())
case "max":
message = fmt.Sprintf("Must be at most %s characters long", e.Param())
case "gte":
message = fmt.Sprintf("Must be greater than or equal to %s", e.Param())
case "username":
message = "Username can only contain letters, numbers, and underscores"
default:
message = fmt.Sprintf("Failed validation: %s", e.Tag())
}
errors = append(errors, ErrorResponse{
Field: strings.ToLower(e.Field()),
Message: message,
})
}
return errors
}
func sanitizeUserInput(user *User) {
user.Username = strings.TrimSpace(user.Username)
user.Email = strings.ToLower(strings.TrimSpace(user.Email))
user.Country = strings.TrimSpace(user.Country)
}
func validateUserHandler(w http.ResponseWriter, r *http.Request) {
var user User
// Decode JSON request
err := json.NewDecoder(r.Body).Decode(&user)
if err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Sanitize input
sanitizeUserInput(&user)
// Basic validation
if err := validate.Struct(user); err != nil {
errors := formatValidationErrors(err)
responseJSON, _ := json.Marshal(errors)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write(responseJSON)
return
}
// Contextual validation
if user.Country == "US" && len(user.Password) < 10 {
error := []ErrorResponse{{
Field: "password",
Message: "US users must have a password of at least 10 characters",
}}
responseJSON, _ := json.Marshal(error)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write(responseJSON)
return
}
// Success response
w.WriteHeader(http.StatusOK)
w.Write([]byte("Validation successful"))
}
func main() {
// Register custom validation function
validate.RegisterValidation("username", isValidUsername)
http.HandleFunc("/validate", validateUserHandler)
log.Println("Server starting on port 8080...")
log.Fatal(http.ListenAndServe(":8080", nil))
}
This comprehensive approach has helped me build applications that handle data reliably while providing a smooth user experience.
Performance Considerations
Extensive validation can impact performance. I optimize validation by:
- Caching validator instances
- Validating only what’s necessary for each operation
- Using appropriate validation techniques based on data complexity
- Implementing early-return patterns for critical validation failures
Validation is a balancing act between thorough checks and system performance. The right approach depends on your application’s specific requirements.
Conclusion
Effective data validation is a cornerstone of robust Go applications. By implementing these seven techniques—struct tag validation, custom validation functions, request middleware, input sanitization, error aggregation, contextual validation, and human-readable error messages—you can build applications that handle data with confidence.
The most effective validation strategies combine multiple approaches tailored to your specific requirements. Start with built-in validation capabilities, add third-party packages for common cases, and implement custom validation for your domain-specific needs.
Remember that validation isn’t just about preventing errors—it’s about creating a better experience for your users and reducing the operational burden on your team.