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.

Imprint

This website is created and run by

Daniel Benner

Zur Deutschen Einheit 2

81929 München

Germany

hello(a)danielbenner.de