Parse, Decode, Validate: A Clean Pattern for Form Handling in Go

Go Web Development

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.