Skip to content
gohacks.dev
APIs

The Validator Package in Go and Its Sharp Edges to Know

Use go-playground/validator in a Go API: tags, nested rules, JSON field names in errors, and the traps around zero values, dive, panics and per-call setup.

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

Struct tags are the most Go-looking way to validate a request. You write the rule on the field, call one function, and get a list of everything that is wrong. The package that popularised this is github.com/go-playground/validator, and for a long time it was the default answer to "how do I validate in Go". It deserves that reputation and it has sharp edges that only show up in production, usually as a bug report that says an order with a quantity of zero was accepted, or a zero discount was rejected.

Tested with Go 1.25.6 and go-playground/validator v10.30.2, macOS on Apple Silicon (arm64)

This article puts the package to work on the same order that hand-written request validation checked, so the two can be compared line for line. It covers the tags that earn their keep, how to turn its errors into the JSON Pointer format used by problem details responses, and the five edges I would put in a code review checklist, each demonstrated by a test that ran. It is a companion to the JSON API cornerstone. Everything ran on Go 1.25.6 with validator v10.30.2.

The model, with the rules on the fields

The types are the same as in the hand-written version, with a validate tag on every field that has a rule.

model.goGo
package main

type Line struct {
	SKU string `json:"sku" validate:"required"`
	Qty int    `json:"qty" validate:"gte=1,lte=100"`
}

type Address struct {
	Street  string `json:"street" validate:"required"`
	Country string `json:"country" validate:"oneof=US DE GB"`
	Postal  string `json:"postal" validate:"required_if=Country US,omitempty,len=5,numeric"`
}

type Order struct {
	Email    string   `json:"email" validate:"required,email"`
	Name     string   `json:"name" validate:"required,min=1,max=80"`
	Currency string   `json:"currency" validate:"oneof=USD EUR GBP"`
	Coupons  []string `json:"coupons" validate:"unique,dive,alphanum"`
	Ship     Address  `json:"ship"`
	Lines    []Line   `json:"lines" validate:"min=1,dive"`
}

The tags read well. required,email means present and an address. oneof=USD EUR GBP is an allowlist. gte=1,lte=100 is a range. unique,dive,alphanum says the list must have no duplicates and, dive descending into the elements, every element must be alphanumeric. min=1,dive on Lines requires at least one line and then validates each. Ship has no tag at all because the package descends into nested structs by default. And required_if=Country US,omitempty,len=5,numeric states the cross-field rule: the postal code is required when the country is US, and when present it must be five digits.

The compact form is the main selling point. Forty fields of "required, max 80" fit on forty lines next to the declarations, and anyone reading the type sees the contract. The cost, which the rest of this article is about, is that the tags are strings. The compiler does not check them, and the behaviors behind them are subtle.

Setting it up so errors are usable

The package's raw output is aimed at developers. An error for the quantity on the second line reports the Go namespace Order.Lines[1].Qty and the tag lte. A client has no use for either. Two pieces of setup fix that.

check.goGo
package main

import (
	"errors"
	"reflect"
	"strings"

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

type FieldError struct {
	Pointer string `json:"pointer"`
	Detail  string `json:"detail"`
}

// New builds the one shared instance. It caches struct metadata, so create it once.
func New() *validator.Validate {
	v := validator.New(validator.WithRequiredStructEnabled())
	// Report the name the client sent, not the Go field name.
	v.RegisterTagNameFunc(func(f reflect.StructField) string {
		name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
		if name == "-" {
			return ""
		}
		return name
	})
	return v
}

// Translate turns validator errors into pointers and sentences a client can use.
func Translate(err error) []FieldError {
	var ve validator.ValidationErrors
	if !errors.As(err, &ve) {
		return []FieldError{{Pointer: "", Detail: err.Error()}}
	}
	out := make([]FieldError, 0, len(ve))
	for _, fe := range ve {
		out = append(out, FieldError{Pointer: pointer(fe.Namespace()), Detail: message(fe)})
	}
	return out
}

// Namespace looks like "Order.lines[1].qty"; the first segment is the type name.
func pointer(ns string) string {
	_, rest, _ := strings.Cut(ns, ".")
	rest = strings.NewReplacer("[", "/", "]", "", ".", "/").Replace(rest)
	return "/" + rest
}

func message(fe validator.FieldError) string {
	switch fe.Tag() {
	case "required", "required_if":
		return "is required"
	case "email":
		return "must be an email address"
	case "oneof":
		return "must be one of " + strings.ReplaceAll(fe.Param(), " ", ", ")
	case "gte", "lte", "min", "max", "len":
		return "fails " + fe.Tag() + "=" + fe.Param()
	case "unique":
		return "must not contain duplicates"
	}
	return "failed " + fe.Tag()
}

New builds the instance once and configures it. RegisterTagNameFunc tells the package to name fields by their JSON tag, so Lines[1].Qty becomes lines[1].qty, which is what the client sent. WithRequiredStructEnabled opts in to the behavior that the required tag applies to struct fields as well, which the documentation recommends and which will become the default in a later major version. Both are one-time settings on the instance.

Translate turns the errors into pointers and sentences. The package returns validator.ValidationErrors, a slice of FieldError, which you reach with errors.As. Each has a Namespace(), a Tag() and a Param(). The pointer function trims the type name and rewrites lines[1].qty to the JSON Pointer /lines/1/qty (RFC 6901), and message maps tags to sentences. The mapping is where most of the real work is. The package ships a translations system for human languages, but if you want a message that says "must be one of USD, EUR, GBP", you write it, and the table in message is the place. Do not pass the default error string to clients; it is shaped like Key: 'Order.Email' Error:Field validation for 'Email' failed on the 'email' tag, and it leaks your type names.

What it returned

The program decodes seven bodies into Order and prints either ok or the translated errors.

main.goGo
package main

import (
	"encoding/json"
	"fmt"
	"strings"
)

func try(label string, v interface{ Struct(any) error }, body string) {
	var o Order
	if err := json.NewDecoder(strings.NewReader(body)).Decode(&o); err != nil {
		fmt.Println(label, "decode:", err)
		return
	}
	err := v.Struct(o)
	if err == nil {
		fmt.Printf("%-26s ok\n", label)
		return
	}
	b, _ := json.Marshal(Translate(err))
	fmt.Printf("%-26s %s\n", label, b)
}

func main() {
	v := New()
	const addr = `"ship":{"street":"s","country":"DE"},"lines":[{"sku":"a","qty":1}]`
	try("valid", v, `{"email":"[email protected]","name":"Ada","currency":"EUR",`+addr+`}`)
	try("many problems", v, `{"email":"ada@","name":"","currency":"JPY","coupons":["X","Y","X"],"ship":{"country":"US","postal":"123"},"lines":[{"sku":"","qty":0},{"sku":"B2","qty":500}]}`)
	try("US without postal", v, `{"email":"[email protected]","name":"Ada","currency":"USD","ship":{"street":"s","country":"US"},"lines":[{"sku":"a","qty":1}]}`)
	try("display-name email", v, `{"email":"Ada <[email protected]>","name":"Ada","currency":"EUR",`+addr+`}`)
	try("a@b (no dot)", v, `{"email":"a@b","name":"Ada","currency":"EUR",`+addr+`}`)
	try("Zoë x 26 (78 runes)", v, `{"email":"[email protected]","name":"`+strings.Repeat("Zoë", 26)+`","currency":"EUR",`+addr+`}`)
	try("Zoë x 27 (81 runes)", v, `{"email":"[email protected]","name":"`+strings.Repeat("Zoë", 27)+`","currency":"EUR",`+addr+`}`)
	try("empty object", v, `{}`)
}
Text
valid                      ok
many problems              [{"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":"must not contain duplicates"},{"pointer":"/ship/street","detail":"is required"},{"pointer":"/ship/postal","detail":"fails len=5"},{"pointer":"/lines/0/sku","detail":"is required"},{"pointer":"/lines/0/qty","detail":"fails gte=1"},{"pointer":"/lines/1/qty","detail":"fails lte=100"}]
US without postal          [{"pointer":"/ship/postal","detail":"is required"}]
display-name email         [{"pointer":"/email","detail":"must be an email address"}]
a@b (no dot)               [{"pointer":"/email","detail":"must be an email address"}]
Zoë x 26 (78 runes)        ok
Zoë x 27 (81 runes)        [{"pointer":"/name","detail":"fails max=80"}]
empty object               [{"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":"fails min=1"}]

The "many problems" request is the same as the one in the hand-written article, and the library reports the same nine locations in the same order: the email, the name, the currency, the duplicate coupon, the missing street, the short postal code, the empty SKU, the zero quantity and the quantity of 500. The pointers match exactly, which is the right check for an equivalent implementation. The sentences for gte and lte are generic ("fails gte=1") because I did not write a better one for them; with a few more cases in message they read as well as the hand-written ones.

The next lines show the useful behaviors. A US address with no postal code gets required_if to fire as "is required" on /ship/postal, a rule that crosses two fields without any code. A display-name email (Ada <[email protected]>) and a bare a@b both fail email, so the package is stricter than net/mail on its own, which is the behavior most forms want. The two Zoë lines pin the length rule: 26 repetitions are 78 characters (104 bytes) and pass max=80; 27 are 81 characters and fail. The package counts runes for strings, so the limit means characters, as a user expects.

The empty object is reported as five required or allowlist failures plus fails min=1 for the missing lines. Notice that /ship/postal is absent: required_if only fires for US.

Edges that bite

The tests below demonstrate the five behaviors I would warn a teammate about. Each passes, which means each behavior is real.

edges_test.goGo
package main

import (
	"strings"
	"testing"

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

// Edge 1: required on a number or bool rejects the legitimate zero value.
func TestRequiredRejectsLegitimateZero(t *testing.T) {
	type Item struct {
		Discount int  `validate:"required"`
		Gift     bool `validate:"required"`
	}
	err := validator.New().Struct(Item{})
	if err == nil || len(err.(validator.ValidationErrors)) != 2 {
		t.Fatalf("expected two failures, got %v", err)
	}
	type Fixed struct {
		Discount *int  `validate:"required"`
		Gift     *bool `validate:"required"`
	}
	zero, no := 0, false
	if err := validator.New().Struct(Fixed{&zero, &no}); err != nil {
		t.Fatalf("pointers should accept zero: %v", err)
	}
}

// Edge 2: a typo in a tag is not a compile error; it panics when the type is first validated.
func TestUnknownTagPanicsAtRuntime(t *testing.T) {
	defer func() {
		r := recover()
		t.Logf("recovered: %v", r)
		if r == nil {
			t.Fatal("expected a panic")
		}
	}()
	type Bad struct {
		Name string `validate:"requried"`
	}
	validator.New().Struct(Bad{})
}

// Edge 3: dive is needed to look inside a slice; without it the elements are not checked.
func TestWithoutDiveElementsAreSkipped(t *testing.T) {
	type Without struct {
		Lines []Line `validate:"min=1"`
	}
	type With struct {
		Lines []Line `validate:"min=1,dive"`
	}
	bad := []Line{{SKU: "", Qty: 0}}
	if err := validator.New().Struct(Without{bad}); err != nil {
		t.Fatalf("unexpected: %v", err)
	}
	if err := validator.New().Struct(With{bad}); err == nil {
		t.Fatal("dive should have found the bad line")
	}
}

// Edge 4: min and max on a string count runes, not bytes.
func TestStringLengthCountsRunes(t *testing.T) {
	type N struct {
		Name string `validate:"max=3"`
	}
	if err := validator.New().Struct(N{"Zoë"}); err != nil {
		t.Fatalf("Zoë is three runes: %v", err)
	}
	if err := validator.New().Struct(N{strings.Repeat("a", 4)}); err == nil {
		t.Fatal("four runes should fail")
	}
}

// Edge 5: a nil pointer to a struct is skipped unless it is marked required.
func TestNilStructPointerIsSkipped(t *testing.T) {
	type Outer struct {
		Inner *Address `validate:"omitempty"`
	}
	type Required struct {
		Inner *Address `validate:"required"`
	}
	if err := validator.New().Struct(Outer{}); err != nil {
		t.Fatalf("nil inner should pass: %v", err)
	}
	if err := validator.New().Struct(Required{}); err == nil {
		t.Fatal("required should reject nil")
	}
}
Text
=== RUN   TestRequiredRejectsLegitimateZero
--- PASS: TestRequiredRejectsLegitimateZero (0.00s)
=== RUN   TestUnknownTagPanicsAtRuntime
    edges_test.go:34: recovered: Undefined validation function 'requried' on field 'Name'
--- PASS: TestUnknownTagPanicsAtRuntime (0.00s)
=== RUN   TestWithoutDiveElementsAreSkipped
--- PASS: TestWithoutDiveElementsAreSkipped (0.00s)
=== RUN   TestStringLengthCountsRunes
--- PASS: TestStringLengthCountsRunes (0.00s)
=== RUN   TestNilStructPointerIsSkipped
--- PASS: TestNilStructPointerIsSkipped (0.00s)
PASS
ok  	example.test/lab	1.294s

1. required rejects a legitimate zero. For numbers, bools and strings, required means "not the zero value". A Discount of 0 and a Gift of false are valid answers, and the first test shows both fail. The fix is a pointer: *int and *bool distinguish "absent" (nil) from "zero" (a pointer to zero), and required on a pointer only checks it is not nil. This is the same distinction JSON null, missing and zero makes at decode time, and it is why request structs in an API often have pointer fields. Think it through before adding required to a numeric field.

2. A typo in a tag panics, at runtime. The second test writes validate:"requried" and the program does not fail to compile. It panics with Undefined validation function 'requried' on field 'Name', and only when that type is validated for the first time. In a service, that means a typo on a rarely used endpoint can sit undetected until a customer hits it. The defence is cheap: a test that calls Struct on a zero or sample value of every request type, so each type's tags are parsed in CI. If you recover panics in middleware, the symptom is a 500 on one route.

3. Slices need dive. The third test shows that min=1 on Lines alone does not look inside the elements: a line with an empty SKU and a zero quantity passed. The package validates the collection with the tags before dive and each element with the tags after it. This is the most common reason that "validation does not work" for a nested list. It applies to maps too, with keys and endkeys for the key rules.

4. Strings count runes. The fourth test confirms max=3 accepts Zoë. This is the right behavior, but if your rule is really about bytes, for a column with a byte limit, the tag cannot express it and you need a custom function.

5. A nil struct pointer is skipped. The fifth test shows that an optional *Address that is nil passes unless you mark it required. If you want the inner struct validated when present and allowed to be absent, use omitempty; if it must be present, use required. Forgetting this means a request with the whole address object missing can slip through, because there is nothing to check.

There are quieter ones as well. Unexported fields are ignored, because the package works through reflection. Cross-field tags such as required_if and eqfield take the Go field name in their parameter, not the JSON name, so renaming a Go field breaks the rule without breaking the build; that is another reason for the every-type test. And a validation that needs a database or the current time does not belong in a tag at all.

What it costs

bench_test.goGo
package main

import "testing"

func BenchmarkStruct(b *testing.B) {
	v := New()
	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() {
		if err := v.Struct(o); err != nil {
			b.Fatal(err)
		}
	}
}

func BenchmarkNewEachTime(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() {
		New().Struct(o)
	}
}
Text
goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkStruct-10         	  876642	      1263 ns/op	     421 B/op	      13 allocs/op
BenchmarkNewEachTime-10    	   57895	     21756 ns/op	   20790 B/op	     301 allocs/op
PASS
ok  	example.test/lab	2.643s

On an Apple M1 Max, validating a valid order with one shared instance took 1.26 µs and 13 allocations. Creating a new instance on every call took 21.7 µs and 301 allocations, about seventeen times slower, because the instance caches the parsed tags of every type it has seen and a new one starts with nothing. The package documentation says the same: the Validate type is safe for concurrent use and meant to be a singleton. Build it at startup, store it on your server struct or in a package variable, and never inside a handler. This is the single most common performance mistake with the library, and it is invisible in a functional test.

For comparison, the hand-written validation of the same order in the previous article ran in 340 ns with 8 allocations on the same machine. The library is about three times slower for this shape and still a rounding error next to a database round trip. Speed is not the reason to choose either.

Custom rules and struct-level checks

Two extension points cover most needs beyond the built-in tags.

RegisterValidation(tag, fn) adds a field-level rule, for example a currency code checked against a list your application owns, or a slug format. The function receives a FieldLevel with the value and the parent struct. Register custom rules when you create the instance, next to the tag-name function.

RegisterStructValidation(fn, Type{}) adds a rule that sees the whole struct and reports against any field. That is the right tool when a rule spans several fields, such as "end must be after start", where tag parameters get awkward. Inside, you call sl.ReportError with the field and tag names to attribute the failure.

At this point the code is closer to the hand-written approach than it started, which is a fair summary of how the two styles meet: the tags handle the 80 percent of rules that are "required, bounded, from a list", and plain Go handles the rest.

Stable codes beat sentences

The message function above returns English text, and that is fine for a developer-facing API. If the people reading your errors use a front end that must localise them, return the tag itself as a stable code next to the sentence: {"pointer":"/lines/1/qty","code":"lte","param":"100"}. A client can then render "At most 100" in any language, and your server stops being the place where wording lives. The package keeps the tag and parameter on every FieldError, so adding the two members to FieldError takes a few lines in Translate. Document the list of codes you emit as part of the contract, and treat renaming one as a breaking change, because someone's translation table keys on it.

It also helps to decide what a client may rely on. The pointer and the code are contract; the sentence is a convenience and may be reworded. That is the same rule the problem details format applies to detail.

When I reach for it and when I do not

Use the package when the request types are numerous and the rules are mostly simple, when several people write handlers and you want the contract visible in the type, or when you generate documentation or client code from the same tags. A service with sixty endpoints and a few hundred fields is a good fit.

Write it by hand when the rules are mostly relational, when there are few types, or when the messages matter enough that you would rather write each than map a tag. The hand-written version is shorter than people expect and has no hidden behavior.

Skip both when an OpenAPI description is your source of truth. Validating requests against the spec (OpenAPI for Go services) means the documented rules and the enforced rules cannot disagree.

Whichever you pick, keep decoding strict (rejecting unknown fields), cap the body size (limiting request body size), and use a separate input type from your storage model so a client cannot set fields it should not (mass assignment). The tag syntax and the full list of validators are in the package documentation on pkg.go.dev.

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.