Form validation in Go handlers tends to sprawl. One handler validates immediately, another waits until halfway through the business logic, a third barely validates at all. The inconsistency creates bugs and makes the codebase harder to reason about.
The fix is a consistent three-step pattern: Parse → Decode → Validate. Put it at the beginning of every handler, every time.
Implementation: A Practical Walkthrough
DTOs as the Foundation
Define a struct for each form. Place it at the top of the handler file - it serves as documentation for what the endpoint accepts:
type taskUpdateForm struct {
Title string `schema:"title" validate:"required,min=1,max=200"`
Status string `schema:"status" validate:"required,oneof=pending in_progress completed"`
}The schema tags map form field names to struct fields. The validate tags enforce constraints: title is required with length limits, status must be one of the allowed values.
The Validation Workflow
Step 1: Parse
if err := r.ParseForm(); err != nil {
h.log.Error().Err(err).Msg("error parsing form")
http.Error(w, "Bad request", http.StatusBadRequest)
return
}Extract form data from the request. Fail early if parsing fails.
Step 2: Decode
decoder := schema.NewDecoder()
var form taskUpdateForm
if err := decoder.Decode(&form, r.PostForm); err != nil {
h.log.Error().Err(err).Msg("error decoding form")
http.Error(w, "Bad request", http.StatusBadRequest)
return
}Gorilla Schema maps form fields to struct fields, handling type conversions.
Step 3: Validate
validate := validator.New()
if err := validate.Struct(form); err != nil {
h.log.Error().Err(err).Msg("validation error")
validationErrors := err.(validator.ValidationErrors)
errorMessages := make([]string, 0, len(validationErrors))
for _, e := range validationErrors {
switch e.Tag() {
case "required":
errorMessages = append(errorMessages, fmt.Sprintf("%s is required", e.Field()))
case "oneof":
errorMessages = append(errorMessages, fmt.Sprintf("%s must be one of: pending, in_progress, completed", e.Field()))
default:
errorMessages = append(errorMessages, fmt.Sprintf("Invalid value for %s", e.Field()))
}
}
http.Error(w, strings.Join(errorMessages, ", "), http.StatusBadRequest)
return
}Go-Playground Validator runs the validation rules from your struct tags. Extract specific error details to return useful messages instead of generic failures.
Complete Example
type taskUpdateForm struct {
Title string `schema:"title" validate:"required,min=1,max=200"`
Status string `schema:"status" validate:"required,oneof=pending in_progress completed"`
}
func (h *Handler) HandleTaskUpdate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
taskID := chi.URLParam(r, "taskID")
// parse
if err := r.ParseForm(); err != nil {
h.log.Error().Err(err).Msg("error parsing form")
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// decode
var form taskUpdateForm
if err := h.decoder.Decode(&form, r.PostForm); err != nil {
h.log.Error().Err(err).Msg("error decoding form")
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// validate
if err := h.validate.Struct(form); err != nil {
validationErrors := err.(validator.ValidationErrors)
errorMessages := make([]string, 0, len(validationErrors))
for _, e := range validationErrors {
switch e.Tag() {
case "required":
errorMessages = append(errorMessages, fmt.Sprintf("%s is required", e.Field()))
case "oneof":
errorMessages = append(errorMessages, fmt.Sprintf("%s must be one of: pending, in_progress, completed", e.Field()))
default:
errorMessages = append(errorMessages, fmt.Sprintf("Invalid value for %s", e.Field()))
}
}
http.Error(w, strings.Join(errorMessages, ", "), http.StatusBadRequest)
return
}
// business logic starts here
task, err := h.taskService.Update(ctx, taskID, form.Title, form.Status)
if err != nil {
h.log.Error().Err(err).Msg("error updating task")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
render.JSON(w, r, task)
}The structure is always the same: extract context, parse, decode, validate, then business logic. Validation errors never leak into your core logic because invalid requests return early.
Why This Works
The pattern does a few things well:
- Consistency - Every handler follows the same structure. Easy to read, easy to modify.
- Separation - Validation happens before business logic. Invalid data can’t corrupt state.
- Testability - Clear boundaries make unit tests straightforward.
It’s not clever. That’s the point. Simple patterns survive contact with growing codebases.