How to Implement Request Validation in Go

Web
Implement request validation in Go by using the validator library to define struct tags and run validation checks.

The problem with trusting user input

A user submits a registration form. The name field is empty. The email field contains user@. The age field holds -5. Your server accepts the payload, passes it to the database, and crashes on a constraint violation. Or worse, it saves the garbage and your downstream services start making decisions based on broken data.

External input is untrusted by default. Go does not validate request payloads automatically. The language gives you the tools to draw boundaries, but you have to write the fence. Validation lives at the edge of your application. It runs before business logic, before database queries, before message queues. It turns raw bytes into verified state.

Garbage in means garbage out. Validate at the edge.

Validation as a contract

Validation is a contract between the outside world and your application. Think of it like a customs checkpoint. Trucks arrive with cargo. Inspectors check the manifests, weigh the loads, and verify the seals. Only approved shipments move past the checkpoint into the domestic supply chain. Everything else gets flagged, quarantined, or turned back.

In Go, that checkpoint is a function or middleware that runs immediately after you decode the request. You define the rules upfront. You run the check. You handle the failure fast. The rest of your codebase assumes the data is clean. That assumption lets you skip defensive checks inside your domain logic and focus on actual business rules.

Struct tags are the most common way to declare those rules in Go. A tag is just a string attached to a struct field. The compiler ignores it. Libraries read it at runtime. That separation keeps your type definitions clean while giving validation libraries a standard hook to work with.

Tags are just strings until something reads them.

A minimal validator

Here is the simplest way to validate a struct using a widely adopted third-party library. The go-playground/validator package reads struct tags and runs the checks for you.

package main

import (
	"fmt"
	"github.com/go-playground/validator/v10"
)

// User represents a registration payload with validation rules attached to each field.
type User struct {
	Name  string `validate:"required,min=3,max=50"` // rejects empty strings and enforces length bounds
	Email string `validate:"required,email"`        // checks for a valid email format
	Age   int    `validate:"gte=18,lte=120"`        // ensures the value falls within a realistic range
}

func main() {
	validate := validator.New() // initializes the validator with default rules
	user := User{Name: "A", Email: "invalid", Age: 15} // deliberately breaks every rule

	if err := validate.Struct(user); err != nil { // runs reflection-based validation
		fmt.Println("Validation failed:", err) // prints aggregated field errors
		return
	}
	fmt.Println("Validation passed") // only reaches here if all tags pass
}

The validate tag key is a convention recognized by the library. The value is a comma-separated list of rules. required checks for zero values. min and max check string length or numeric bounds. email runs a regex pattern. The library aggregates failures into a single error value. You get all the problems at once instead of stopping at the first one.

Reflection is fast enough for request boundaries. Don't optimize it away prematurely.

What happens under the hood

When you call validate.Struct(user), the library uses the reflect package to inspect the User type. It walks through each field, reads the struct tag, and splits the tag string by commas. Each rule name maps to a registered validation function. The library calls those functions with the field value. If a function returns false, the library records a field error.

This process happens at runtime. The compiler does not verify that min=3 is a valid rule. It does not check that email exists. Typos in tag names silently fail. The validator will just ignore unknown rules and report no error for that field. That is why you need tests that deliberately submit invalid payloads and assert that validation catches them.

The cost of reflection is measurable but small. A typical HTTP request spends most of its time waiting for network I/O or database queries. The few microseconds spent walking struct tags are negligible. You only need to worry about reflection overhead if you are validating millions of tiny structs in a tight loop without I/O. For request boundaries, the tradeoff is worth it.

The compiler won't catch a typo in a struct tag. Your tests will.

Validation in a real HTTP handler

Real applications validate inside HTTP handlers. You decode the JSON body, run the validator, and return a structured error response. Here is how that looks in production code.

package main

import (
	"encoding/json"
	"net/http"
	"github.com/go-playground/validator/v10"
)

// RegisterHandler processes user registration requests with strict input validation.
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
	var payload User // declares the target struct for JSON unmarshaling

	if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { // reads and parses the request body
		http.Error(w, "invalid JSON", http.StatusBadRequest) // fails fast on malformed payloads
		return
	}

	validate := validator.New() // creates a fresh validator instance per request
	if err := validate.Struct(payload); err != nil { // runs the tag-based rules
		w.Header().Set("Content-Type", "application/json") // ensures the client expects JSON
		w.WriteHeader(http.StatusBadRequest) // signals client-side validation failure
		json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) // returns human-readable details
		return
	}

	// payload is now verified. Safe to pass to database or business logic.
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"status": "registered"})
}

The if err != nil pattern is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path visible. You cannot accidentally swallow a validation failure behind a silent recover or a forgotten return. Every branch is explicit.

Notice that context.Context does not appear here. Validation is usually synchronous and fast. You only need a context if your validation calls external services, hits a cache, or needs a deadline. When you do add a context, it always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.

Fail fast. Return a 400 before the request touches your database.

Common traps and compiler complaints

Validation code looks simple until it breaks. The most common mistake is forgetting to export struct fields. Reflection in Go can only read capitalized field names. If you write name string instead of Name string, the validator skips it entirely. The compiler will not warn you. The field just passes validation by default.

Another trap is treating the validation error as a plain error. The go-playground/validator package returns a custom type that implements the error interface. If you try to cast it directly without type assertion, the compiler rejects the program with cannot use err (type error) as type validator.ValidationErrors in argument. You need to type assert or use the package's helper functions to extract individual field errors.

Forget to import the validator package and you get undefined: validator from the compiler. Forget to use one and you get imported and not used. Go's strict import rules keep your dependency tree clean. They also force you to acknowledge every external package you bring into a file.

Custom validation rules require registration. If you write validate:"required,custom_rule" but never register custom_rule with the validator instance, the library ignores it. You will think the rule is running when it is not. Always verify custom rules in isolation before attaching them to production structs.

The worst validation bug is the one that silently passes. Write negative tests.

Choosing your validation strategy

Validation tools exist on a spectrum. The right choice depends on your payload complexity, your team's familiarity with the ecosystem, and how much control you need over error messages.

Use manual if checks when you have two or three simple fields and want zero dependencies. Write explicit comparisons, return early on failure, and keep the logic inline with your handler.

Use go-playground/validator when you have complex structs with repeated rules across your codebase. The tag syntax reduces boilerplate, the library handles edge cases like time parsing and slice bounds, and the community maintains it actively.

Use a dedicated validation framework like go-ozzo/ozzo-validation when you need custom business logic that does not fit tag syntax. These libraries let you chain methods, attach validators to interfaces, and generate localized error messages without parsing strings.

Use JSON Schema validation when your API contract is defined externally and you need machine-readable schemas. Tools like invopop/jsonschema generate Go structs from schemas and validate payloads against the original specification.

Stick to standard library encoding/json unmarshaling errors when you only need to verify the payload parses correctly. If the JSON is well-formed and the types match, you might not need additional validation at all.

Pick the tool that matches your complexity. Don't import a library to check one string.

Where to go next