Go Web Frameworks: net/http, Chi, Gin, Echo and Fiber · Building Production APIs in Go

Validation and Consistent Error Responses

Validate request bodies with struct tags, turn validator errors into clear per-field messages, and return every error in one predictable JSON shape with the right status code.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A POST /users endpoint that answers bad input with:

{"error": "validation_failed", "fields": {"email": "must be a valid email", "age": "must be at least 13"}}

and uses the same error shape for 400, 404, 409 and 500 responses everywhere in the API.

The idea

  • Validate with struct tags using github.com/go-playground/validator/v10 (the library behind Gin's binding): validate:"required,email", min, max, oneof=admin user, gte, nested structs and slices with dive.
  • Translate validator.ValidationErrors into friendly messages keyed by the JSON field name (register a tag-name function so errors say email, not Email).
  • One error type for the API, for example APIError{Status, Code, Message, Fields}, and one function that writes it. Handlers return or write APIErrors; unexpected errors become a generic 500 that never leaks internals.
  • Use the right status: 400 malformed JSON, 422 valid JSON but invalid values, 404 not found, 409 conflict (email already used), 500 unexpected.

The code

Field-level validation errors in one consistent shape
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"reflect"
	"strings"

	"github.com/go-playground/validator/v10"
)

type CreateUser struct {
	Email string `json:"email" validate:"required,email"`
	Name  string `json:"name" validate:"required,min=2,max=50"`
	Age   int    `json:"age" validate:"gte=13"`
	Role  string `json:"role" validate:"oneof=user admin"`
}

type APIError struct {
	Status  int               `json:"-"`
	Code    string            `json:"error"`
	Message string            `json:"message,omitempty"`
	Fields  map[string]string `json:"fields,omitempty"`
}

func (e *APIError) Error() string { return e.Code }

var validate = validator.New()

func init() {
	// Report JSON names ("email") instead of Go field names ("Email").
	validate.RegisterTagNameFunc(func(f reflect.StructField) string {
		return strings.Split(f.Tag.Get("json"), ",")[0]
	})
}

func message(fe validator.FieldError) string {
	switch fe.Tag() {
	case "required":
		return "is required"
	case "email":
		return "must be a valid email"
	case "min":
		return "must be at least " + fe.Param() + " characters"
	case "gte":
		return "must be at least " + fe.Param()
	case "oneof":
		return "must be one of: " + fe.Param()
	}
	return "is invalid"
}

func validationError(err error) *APIError {
	var ves validator.ValidationErrors
	if !errors.As(err, &ves) {
		return &APIError{Status: 500, Code: "internal_error"}
	}
	fields := map[string]string{}
	for _, fe := range ves {
		fields[fe.Field()] = message(fe)
	}
	return &APIError{Status: http.StatusUnprocessableEntity, Code: "validation_failed", Fields: fields}
}

func writeError(w http.ResponseWriter, err error) {
	var apiErr *APIError
	if !errors.As(err, &apiErr) {
		apiErr = &APIError{Status: 500, Code: "internal_error", Message: "something went wrong"}
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(apiErr.Status)
	json.NewEncoder(w).Encode(apiErr)
}

var taken = map[string]bool{"[email protected]": true}

func createUser(w http.ResponseWriter, r *http.Request) {
	var in CreateUser
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeError(w, &APIError{Status: 400, Code: "bad_json", Message: "request body must be valid JSON"})
		return
	}
	if err := validate.Struct(in); err != nil {
		writeError(w, validationError(err))
		return
	}
	if taken[in.Email] {
		writeError(w, &APIError{Status: 409, Code: "email_taken", Message: "an account with this email exists"})
		return
	}
	w.WriteHeader(http.StatusCreated)
	fmt.Fprintln(w, `{"status":"created"}`)
}

func main() {
	for _, body := range []string{
		`{"email":"[email protected]","name":"Ben","age":30,"role":"user"}`,
		`{"email":"not-an-email","name":"B","age":9,"role":"owner"}`,
		`{"email":"[email protected]","name":"Ana","age":40,"role":"admin"}`,
		`{"email":`,
	} {
		rec := httptest.NewRecorder()
		createUser(rec, httptest.NewRequest("POST", "/users", strings.NewReader(body)))
		fmt.Printf("%d %s", rec.Code, rec.Body.String())
	}
}

Output

201 {"status":"created"}
422 {"error":"validation_failed","fields":{"age":"must be at least 13","email":"must be a valid email","name":"must be at least 2 characters","role":"must be one of: user admin"}}
409 {"error":"email_taken","message":"an account with this email exists"}
400 {"error":"bad_json","message":"request body must be valid JSON"}

Run this on your computer — see “Get Started” for setup.

Go's JSON encoder writes map keys in sorted order, so the fields object always comes out the same way, which keeps client tests and snapshots stable.

Common mistakes

  • Leaking the raw validator message ("Key: 'CreateUser.Email' Error:…") to users.
  • Go field names in errors. Clients send email, so report email.
  • 400 for everything. Distinguish malformed JSON (400), invalid values (422) and conflicts (409); clients handle them differently.
  • Different error shapes per endpoint. Front-end and mobile code then needs special cases everywhere.
  • Validating only on the client. The server is the source of truth.

Key points

  • Struct-tag validation with go-playground/validator; JSON names via a tag-name function.
  • Translate field errors into friendly messages keyed by field.
  • One APIError type and one writer; unexpected errors become a generic 500.
  • Use 400 / 422 / 404 / 409 / 500 deliberately.

Further reading: validator v10 documentation, RFC 9457: Problem Details for HTTP APIs.

Practice questions

When would an API return 400 vs 422 for bad input?

Answer

400 Bad Request when the request can't be parsed at all (malformed JSON, wrong content type). 422 Unprocessable Content when it parses but the values break the rules (invalid email, age below the minimum).

How do you make go-playground/validator report JSON field names?

Answer

Register a tag-name function with validate.RegisterTagNameFunc that returns the first part of the struct field's json tag; FieldError.Field() then returns names like "email" instead of "Email".

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?