Skip to content
gohacks.dev
APIs

Request Validation in Go APIs Without Reflection Magic

Validate Go API requests with a small collector: JSON Pointer paths, nested fields, cross-field rules, rune-aware lengths and 340 ns for a valid order.

17 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

Every handler that reads a body has the same two steps after decoding: decide whether the data makes sense, and tell the caller exactly what does not. The first is easy. The second is where most APIs fall down, returning one error at a time, naming a Go struct field the client has never heard of, or reporting Key: 'Order.Lines[1].Qty' Error:Field validation for 'Qty' failed on the 'max' tag, which is a message written for the person who wrote the code and read by the person calling it.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds request validation from small parts, with the standard library and a few dozen lines of helpers. It collects every problem in one pass, reports them as JSON Pointers a client can use to highlight the right input, handles nested objects and arrays, expresses rules that cross fields, and costs a few hundred nanoseconds. The next article, the validator package and its sharp edges, covers the tag-based alternative, so you can pick with both in view. The result is the input half of the JSON API cornerstone, and it feeds the error document described in problem details. Everything ran on Go 1.25.6.

Validation has three layers, and they are different jobs

It helps to separate three checks that tutorials blur together.

Syntax: is the body JSON at all, and does it fit the Go types? This belongs to the decoder. A string where a number should be, a trailing comma, or two values in one body are all failures of syntax. They are a 400, and the decoder reports them. Use DisallowUnknownFields so that a misspelled field ("emial") is an error instead of a silently ignored one, as covered in rejecting unknown fields.

Shape and range: is the value acceptable on its own? A name that is present and not too long, a quantity between 1 and 100, a currency from a short list. These are the checks this article is about. They are a 422 in my convention, because the request was well formed but breaks a rule.

State and business rules: is the value acceptable given everything else? The SKU exists, the coupon has not expired, the customer is allowed to order that many. These need the database and belong in the service layer, which returns domain errors that the error mapper turns into problems. Mixing them into the validator makes it slow, hard to test and tempting to call from places that should not touch storage.

Keeping the layers separate also keeps the rule about where the work happens simple: the validator is a pure function of the decoded value. No I/O, no clock, no context. That is why it is fast and why its tests need no setup.

A collector, not a first-error return

The helper below is the whole library. It holds the current path and a list of errors, and every check appends to the list instead of returning early.

validate.goGo
package main

import (
	"fmt"
	"net/mail"
	"net/url"
	"slices"
	"strings"
	"unicode/utf8"
)

// FieldError is one problem at one JSON Pointer.
type FieldError struct {
	Pointer string `json:"pointer"`
	Detail  string `json:"detail"`
}

// V collects every problem instead of stopping at the first. The zero value is ready to use.
type V struct {
	path []string
	errs []FieldError
}

func (v *V) add(msg string) {
	v.errs = append(v.errs, FieldError{Pointer: "/" + strings.Join(v.path, "/"), Detail: msg})
}

// Field runs fn with name pushed on the pointer, so nested checks report where they are.
func (v *V) Field(name string, fn func()) {
	v.path = append(v.path, name)
	fn()
	v.path = v.path[:len(v.path)-1]
}

// Each validates a slice, reporting the index in the pointer.
func Each[T any](v *V, name string, items []T, fn func(T)) {
	v.Field(name, func() {
		for i, it := range items {
			v.Field(fmt.Sprint(i), func() { fn(it) })
		}
	})
}

func (v *V) Err() []FieldError { return v.errs }
func (v *V) OK() bool          { return len(v.errs) == 0 }

func (v *V) Required(s string) bool {
	if strings.TrimSpace(s) == "" {
		v.add("is required")
		return false
	}
	return true
}

// Length counts runes, so "Zoë" is three characters and not four bytes.
func (v *V) Length(s string, min, max int) {
	if n := utf8.RuneCountInString(s); n < min || n > max {
		v.add(fmt.Sprintf("must be %d to %d characters", min, max))
	}
}

func (v *V) Range(n, min, max int) {
	if n < min || n > max {
		v.add(fmt.Sprintf("must be between %d and %d", min, max))
	}
}

func (v *V) OneOf(s string, allowed ...string) {
	if !slices.Contains(allowed, s) {
		v.add("must be one of " + strings.Join(allowed, ", "))
	}
}

// Email accepts a bare address only; mail.ParseAddress alone would also accept "Name <[email protected]>".
func (v *V) Email(s string) {
	a, err := mail.ParseAddress(s)
	if err != nil || a.Address != s || !strings.Contains(a.Address[strings.LastIndex(a.Address, "@"):], ".") {
		v.add("must be an email address")
	}
}

func (v *V) HTTPURL(s string) {
	u, err := url.Parse(s)
	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
		v.add("must be an http or https URL")
	}
}

func (v *V) Unique(items []string) {
	seen := map[string]bool{}
	for _, s := range items {
		if seen[s] {
			v.add("contains a duplicate: " + s)
			return
		}
		seen[s] = true
	}
}

There are four ideas in it.

V collects. Required, Length, Range, OneOf, Email, HTTPURL and Unique are methods on a collector. They do not return an error; they append one. The zero value is ready to use, so a validation function starts with var v V and ends with return v.Err().

Field names the location. v.Field("email", func() { ... }) pushes email onto the path, runs the function, then pops it. Inside, a failed check produces a pointer like /email; nested twice, /ship/country. The closure form reads like the structure of the data, and it makes it impossible to forget the pop. Each does the same for slices and puts the index in the path, so the second line of an order reports as /lines/1/qty. A client can map that pointer to an input in a form without parsing English, which is the whole purpose of RFC 6901.

Length counts runes. len("Zoë") is 4 because ë takes two bytes. A limit of "at most 80 characters" that counts bytes rejects an honest name from a user whose alphabet is not ASCII. utf8.RuneCountInString is the right primitive for names and titles; if your rule is really about storage, say so and count bytes on purpose. For text that users see as characters, such as combined emoji and accents, even runes can disagree with what the eye counts, and the grapheme clusters needed for that are a library concern. Rune counting is the sensible default.

Email is stricter than the standard parser. mail.ParseAddress follows the RFC 5322 grammar, which accepts Ada <[email protected]> and, with a bare a@b, a local-network address. For a signup form you almost always want a plain address with a dot in the domain. The helper compares the parsed address with the input to reject display names, and requires a dot after the last @. The honest truth about email validation is that nothing short of sending a message proves an address works, so this is a filter for typos, not a guarantee.

Validating a real shape

An order has scalars, a nested address, a list of coupons and a list of lines, which exercises every part of the helper.

order.goGo
package main

import "strings"

type Line struct {
	SKU string `json:"sku"`
	Qty int    `json:"qty"`
}

type Address struct {
	Street  string `json:"street"`
	Country string `json:"country"`
	Postal  string `json:"postal"`
}

type Order struct {
	Email    string   `json:"email"`
	Name     string   `json:"name"`
	Currency string   `json:"currency"`
	Coupons  []string `json:"coupons"`
	Ship     Address  `json:"ship"`
	Lines    []Line   `json:"lines"`
}

// Validate normalises first, then checks. Trimming belongs here, not in the handler.
func (o *Order) Validate() []FieldError {
	o.Email = strings.ToLower(strings.TrimSpace(o.Email))
	o.Name = strings.TrimSpace(o.Name)
	var v V
	v.Field("email", func() {
		if v.Required(o.Email) {
			v.Email(o.Email)
		}
	})
	v.Field("name", func() {
		if v.Required(o.Name) {
			v.Length(o.Name, 1, 80)
		}
	})
	v.Field("currency", func() { v.OneOf(o.Currency, "USD", "EUR", "GBP") })
	v.Field("coupons", func() { v.Unique(o.Coupons) })
	v.Field("ship", func() {
		v.Field("street", func() { v.Required(o.Ship.Street) })
		v.Field("country", func() { v.OneOf(o.Ship.Country, "US", "DE", "GB") })
		v.Field("postal", func() {
			if o.Ship.Country == "US" && len(o.Ship.Postal) != 5 { // a rule across two fields
				v.add("must be 5 digits for US addresses")
			}
		})
	})
	v.Field("lines", func() {
		if len(o.Lines) == 0 {
			v.add("must contain at least one line")
		}
	})
	Each(&v, "lines", o.Lines, func(l Line) {
		v.Field("sku", func() { v.Required(l.SKU) })
		v.Field("qty", func() { v.Range(l.Qty, 1, 100) })
	})
	return v.Err()
}

A few things in Validate are worth stealing.

It normalises before it checks. The email is lowercased and trimmed, the name trimmed. This is deliberate: validating " [email protected] " as it arrived and storing it as it arrived leaves you with two accounts for one person. Doing the normalisation in the same method means the stored value is the checked value. Because the method takes a pointer receiver, the handler sees the cleaned order afterwards.

Required then format. if v.Required(o.Email) { v.Email(o.Email) } stops an empty email from producing both "is required" and "must be an email address". One problem per field is enough for the person fixing it, and Required returns whether the value was present so dependent checks can skip themselves.

Cross-field rules are just code. The postal code check says: only when the country is US, the code must be five characters. In a struct tag language this needs a special tag, a custom function or both. Here it is an if, and it reports against /ship/postal, which is the field the user needs to change.

Collections are checked twice on purpose. lines has a rule on the collection (at least one) and rules on each element. They are separate calls so the collection error and the element errors do not interfere, and because the pointer for the first is /lines and for the second /lines/0/sku.

Closures cost little but not nothing. Each Field call allocates a closure when it captures variables, and the benchmark below shows it: eight allocations for a valid order. That is a fine price for readability in a request handler. Where it would not be, you can flatten hot paths with a plain loop.

The handler

The endpoint decodes strictly, validates, and writes either a 422 listing every problem or the success status. To make the behavior easy to read, the program below drives the handler with four bodies.

main.goGo
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

func create(w http.ResponseWriter, r *http.Request) {
	var o Order
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
	dec.DisallowUnknownFields()
	if err := dec.Decode(&o); err != nil {
		w.Header().Set("Content-Type", "application/problem+json")
		w.WriteHeader(400)
		json.NewEncoder(w).Encode(map[string]any{"title": "Malformed body", "status": 400, "detail": err.Error()})
		return
	}
	if errs := o.Validate(); len(errs) > 0 {
		w.Header().Set("Content-Type", "application/problem+json")
		w.WriteHeader(422)
		json.NewEncoder(w).Encode(map[string]any{"title": "Your request is not valid", "status": 422, "errors": errs})
		return
	}
	w.WriteHeader(201)
	fmt.Fprintf(w, `{"email":%q}`+"\n", o.Email)
}

func main() {
	good := `{"email":"  [email protected] ","name":"Ada","currency":"EUR","ship":{"street":"1 Main","country":"DE","postal":"10115"},"lines":[{"sku":"A1","qty":2}]}`
	bad := `{"email":"ada@","name":"","currency":"JPY","coupons":["X","Y","X"],"ship":{"country":"US","postal":"123"},"lines":[{"sku":"","qty":0},{"sku":"B2","qty":500}]}`
	empty := `{}`
	unknown := `{"emial":"[email protected]"}`

	for _, c := range []struct{ label, body string }{{"valid", good}, {"many problems", bad}, {"empty object", empty}, {"misspelled field", unknown}} {
		rec := httptest.NewRecorder()
		create(rec, httptest.NewRequest("POST", "/orders", strings.NewReader(c.body)))
		fmt.Printf("--- %s: %d\n%s", c.label, rec.Code, rec.Body.String())
	}
}
Text
--- valid: 201
{"email":"[email protected]"}
--- many problems: 422
{"errors":[{"pointer":"/email","detail":"must be an email address"},{"pointer":"/name","detail":"is required"},{"pointer":"/currency","detail":"must be one of USD, EUR, GBP"},{"pointer":"/coupons","detail":"contains a duplicate: X"},{"pointer":"/ship/street","detail":"is required"},{"pointer":"/ship/postal","detail":"must be 5 digits for US addresses"},{"pointer":"/lines/0/sku","detail":"is required"},{"pointer":"/lines/0/qty","detail":"must be between 1 and 100"},{"pointer":"/lines/1/qty","detail":"must be between 1 and 100"}],"status":422,"title":"Your request is not valid"}
--- empty object: 422
{"errors":[{"pointer":"/email","detail":"is required"},{"pointer":"/name","detail":"is required"},{"pointer":"/currency","detail":"must be one of USD, EUR, GBP"},{"pointer":"/ship/street","detail":"is required"},{"pointer":"/ship/country","detail":"must be one of US, DE, GB"},{"pointer":"/lines","detail":"must contain at least one line"}],"status":422,"title":"Your request is not valid"}
--- misspelled field: 400
{"detail":"json: unknown field \"emial\"","status":400,"title":"Malformed body"}

Read the four results.

The valid body returns 201, and the stored email is [email protected]: the input was " [email protected] ". The normalisation did its job without the handler knowing.

The second body is wrong in nine ways at once, and the response lists all nine in the order the rules ran: the email (ada@ has no domain), the empty name, the currency JPY that is not on the list, a duplicate coupon, the missing street, the postal code that is too short for a US address, the empty SKU on the first line, a quantity of zero on the same line, and a quantity of 500 on the second. A client can highlight nine inputs from one response. Compare that with the usual experience of fixing one field, submitting, and meeting the next. This is the practical argument for collecting: the cost of an extra error in the response is a few bytes, and the cost of an extra round trip is a person's patience.

The third body, {}, shows that required fields and defaults behave: the missing email, name, street and lines are reported, and so are currency and ship.country, because the zero value "" is not in their allowed lists. That is something to decide on purpose. If a field has a default, apply it before validating, otherwise the validator will report the zero value as invalid.

The fourth body misspells email as emial, and the decoder rejects it with a 400 and a message naming the field. Without DisallowUnknownFields this request would have decoded to an empty Email, produced "email is required", and sent the user hunting for a field they filled in. Strict decoding turns that into an error that points at the real problem.

Tests

val_test.goGo
package main

import (
	"fmt"
	"testing"
)

func has(errs []FieldError, pointer string) bool {
	for _, e := range errs {
		if e.Pointer == pointer {
			return true
		}
	}
	return false
}

func TestNestedAndIndexedPointers(t *testing.T) {
	o := Order{Lines: []Line{{SKU: "A", Qty: 1}, {SKU: "", Qty: 0}}}
	errs := o.Validate()
	for _, p := range []string{"/lines/1/sku", "/lines/1/qty", "/ship/country", "/email"} {
		if !has(errs, p) {
			t.Errorf("missing %s in %v", p, errs)
		}
	}
	if has(errs, "/lines/0/sku") {
		t.Errorf("line 0 is valid: %v", errs)
	}
}

func TestLengthCountsRunes(t *testing.T) {
	var v V
	v.Length("Zoë", 3, 3)
	if !v.OK() {
		t.Fatalf("Zoë should be 3 characters: %v", v.Err())
	}
}

func TestEmailRejectsDisplayNames(t *testing.T) {
	for in, ok := range map[string]bool{"[email protected]": true, "A <[email protected]>": false, "a@b": false, "": false} {
		var v V
		v.Email(in)
		if v.OK() != ok {
			t.Errorf("%q: ok=%v", in, v.OK())
		}
	}
}

func TestValidInputAllocationCount(t *testing.T) {
	o := Order{Email: "[email protected]", Name: "A", Currency: "USD", Ship: Address{Street: "s", Country: "DE"}, Lines: []Line{{SKU: "x", Qty: 1}}}
	n := testing.AllocsPerRun(100, func() {
		if errs := o.Validate(); len(errs) != 0 {
			panic(fmt.Sprint(errs))
		}
	})
	t.Logf("allocations per valid Validate: %.0f", n)
}
Text
=== RUN   TestNestedAndIndexedPointers
--- PASS: TestNestedAndIndexedPointers (0.00s)
=== RUN   TestLengthCountsRunes
--- PASS: TestLengthCountsRunes (0.00s)
=== RUN   TestEmailRejectsDisplayNames
--- PASS: TestEmailRejectsDisplayNames (0.00s)
=== RUN   TestValidInputAllocationCount
    val_test.go:55: allocations per valid Validate: 8
--- PASS: TestValidInputAllocationCount (0.00s)
PASS
ok  	example.test/lab	1.332s

The tests cover the properties that regress quietly. The first asserts that nested and indexed pointers are correct and, just as important, that the valid first line produced no error. The second pins that Zoë is three characters. The third checks the email rules, including the display-name case. The fourth records the allocation count for a valid input, which is a number to watch rather than a limit to enforce; if a change doubles it, the test log is where you notice.

Table tests are the natural fit for the individual rules. Each rule gets a table of inputs and whether they pass, as the email test does, and the combined Order test only needs to prove that the pieces are wired to the right pointers. When a client reports "your API rejected my valid order", you add the order to a table and it becomes a regression test.

What it costs

bench_test.goGo
package main

import "testing"

func BenchmarkValidate(b *testing.B) {
	o := Order{Email: "[email protected]", Name: "A", Currency: "USD", Ship: Address{Street: "s", Country: "DE"}, Lines: []Line{{SKU: "x", Qty: 1}}}
	b.ReportAllocs()
	for b.Loop() {
		o.Validate()
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkValidate-10    	 3541770	       340.3 ns/op	     192 B/op	       8 allocs/op
PASS
ok  	example.test/lab	1.444s

On an Apple M1 Max with Go 1.25.6, validating a valid order takes 340 ns with 192 bytes and 8 allocations. The allocations come from the closures, the path slice growing and the address parse, and none of it is worth removing: a request that reaches a database spends thousands of times longer there. I include the number to close an argument, not to start one. Hand-written validation is not slower than a library in any way that matters for an API, and a library is not slower than hand-written code in any way a user would notice. The decision is about how the rules read, how they are tested and who maintains them.

Where hand-written validation hurts

I prefer this style, and it has costs worth stating.

It is more lines. A struct with forty fields needs forty checks, where tags would put each on the field declaration. The tags have the advantage that the rule sits next to the type. If your models are large and mostly "required, max length", a tag-based library saves real typing. Compare in the validator package article.

It does not generate documentation. The rules live in code, so an OpenAPI description has to be written or generated separately from them. Tools that read tags can emit maxLength and enum for you. If your contract is the spec (OpenAPI for Go services), you can validate requests against the spec itself instead.

Messages are English. The sentences above are fixed strings. For an API read by developers that is normal; for an API whose messages reach end users, return stable codes (too_short, required) alongside the text and let the client localise them, or localise on the server as in internationalisation.

Consistency is up to you. Without a shared helper, three developers write three different Requireds. The V type is the shared helper; keep it in one package and add rules to it rather than inlining them.

Security notes

Validation is the first defence, not the only one. A few things belong beside it.

Limit the size before you decode. The handler wraps the body in http.MaxBytesReader, so a client cannot make the server read gigabytes (limiting request body size). Validate the number of elements as well as their content: an array of a million small objects fits in a few megabytes and still costs real CPU, so cap the length of lines (the sample caps quantity, and a cap on count is an easy addition) and, for deeply nested input, see JSON depth limits.

Validate what you will use as an identifier. A value that becomes a file name, a table name, a header or a URL is an injection vector. For file names and paths, check against an allowlist or use filepath.IsLocal. For redirect targets and fetched URLs, parse and check the host; the SSRF article explains why a scheme check alone is not enough.

Validate then use the validated value. A value normalised during validation, like the email above, should be the one stored. Passing the original along beside it is how a trimmed address gets checked and an untrimmed one saved.

Do not trust the client's validation. Browser-side checks are for usability. The server is the only place a rule is enforced.

Reuse across entry points

A validator that is a method on the type, func (o *Order) Validate() []FieldError, can be reused anywhere the type appears: an HTTP handler, a queue consumer, a CLI import, a test fixture builder. That is the argument for keeping it a pure function of the value, with no reference to http.Request. The handler turns the list into a problem document, the consumer logs it and dead-letters the message, and the CLI prints it. Define a small interface if you want the handler to be generic:

Go
type Validatable interface{ Validate() []FieldError }

and a generic decodeAndValidate[T Validatable] helper removes the repeated three steps from every endpoint. Pair it with the error mapping from problem details and a handler body becomes decode, call the service, write the result.

Common mistakes

Returning at the first error. It is the default shape of func validate() error, and it is the most annoying behaviour an API can have. Collect.

Validating the wrong type. Checking the database model rather than the request type lets an attacker set fields that should never come from a client; see mass assignment. The input struct lists only what a caller may set.

Treating zero as missing. Qty == 0 is a valid answer in some APIs and a missing field in others. If the difference matters, use a pointer so you can tell absent from zero, as in telling null, missing and zero apart.

Putting database calls inside the validator. It makes the validator slow and non-deterministic, and it leaks into tests. Do uniqueness checks in the service, and rely on a unique constraint for correctness, since two requests can pass a check and still collide.

Echoing input in messages. A message that includes the offending value can reflect attacker-controlled text into logs and responses. Name the rule, not the value.

For a summary of the rest of the surrounding design, common REST API mistakes lists the traps in order, and the standard library pieces used here are in the net/mail, unicode/utf8 and encoding/json documentation.

Also inSecurity
Taggedjson
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.