Skip to main content
Version: Next

Performance Optimization

How Pedantigo achieves its speed, and how to get the most out of it. Every number on this page comes from a real benchmark run in the companion pedantigo-benchmarks repository — see the full Benchmark Results for the complete cross-library comparison and raw data behind every figure here.

How Is Pedantigo So Fast?

Compared to go-playground/validator — the most direct competitor, since both use struct tags — Pedantigo wins on two concrete, code-level design choices.

1. Generics remove a lookup playground is structurally forced to do

Playground's entry point takes an untyped value: func (v *Validate) Struct(s interface{}) error. Because the concrete type is erased behind interface{}, playground cannot know at compile time which struct's rules apply — it has to ask at runtime, on every single call:

// go-playground/validator internals (validator.go)
cs, ok := v.v.structCache.Get(typ) // map[reflect.Type]*cStruct lookup, guarded by atomic.Value

This lookup runs not just once per Struct() call but once per nested struct too — validateStruct calls itself recursively for every embedded struct field, repeating the same lookup each time.

Pedantigo's Validator[T] is generic over T. When you call validator.New[User](), the Go compiler already knows the type — there is nothing to look up. The resulting *Validator[T] carries its own precomputed fieldCache as a plain struct field, built once inside New(). Validate() reads that field directly — no map read, no type dispatch, at call time, ever.

2. The cache itself has a flatter, more cache-friendly shape

Pedantigo's field cache is a plain slice of value structs — one contiguous block of memory:

type FieldCache struct {
Fields []CachedField // indexed by struct field order
}

Playground's equivalent is a slice of pointers to individually heap-allocated structs, and each field's parsed constraints form a linked list rather than a slice:

// go-playground/validator internals (cache.go)
type cStruct struct {
fields []*cField // pointer per field, not value
}
type cTag struct {
next *cTag // constraints on one field are a linked list
}

Validating one field with several constraints in playground means chasing several separate pointers scattered across the heap. Pedantigo walks one contiguous slice.

What this does not mean

Neither library eliminates reflection outright. Both still call reflect.Value.Field(i) to actually read each field's value at validate time — generics don't remove that. What generics remove specifically is the "which type is this, and where's its cache" dispatch step, not the field read itself.

Measured consequence

BenchmarkPedantigoPlaygroundGap
Validate_Simple560 ns / 10 allocs801 ns / 7 allocs1.43x faster — despite more allocations
Validate_Complex963 ns / 15 allocs1.43 µs / 9 allocs1.48x faster
JSONValidate_Simple1.41 µs / 19 allocs1.67 µs / 16 allocs1.19x faster
Marshal_Simple711 ns / 11 allocs973 ns / 9 allocs1.37x faster

Playground consistently does fewer allocations yet is still slower — consistent with the cost being per-call lookup and pointer-chasing overhead, not allocation count. See the full benchmark report for every library and category, including Unmarshal (single-call decode+validate), where Pedantigo is also fastest against its two closest peers (godasse, godantic).


What New() Actually Does

New[T]() does the expensive work once. It walks the struct via reflection, resolves every constraint tag, and builds the fieldCache described above, plus the JSON field deserializers used by Unmarshal. That one-time cost is real:

StructTimeAllocations
Simple (5 fields)6.70 µs129
Complex (nested)16.18 µs299

Every other operation — Validate, Unmarshal, Marshal, Schema — reuses that precomputed cache instead of redoing this work, which is why they run in the hundreds-of-ns to low-µs range:

OperationSimpleComplex
Validate560 ns / 10 allocs963 ns / 15 allocs
Unmarshal (decode + validate, one call)1.94 µs / 39 allocs6.47 µs / 122 allocs
Marshal711 ns / 11 allocs
Schema() — first call11.38 µs / 227 allocs
Schema() — cached9 ns / 0 allocs

Schema caching is the largest single win on this page: ~1,264x faster once cached (11.38 µs → 9 ns), not the "240x" figure quoted elsewhere in older docs — that number was never benchmarked and should be treated as superseded by the figures on this page.

This only pays off if the *Validator[T] returned by New is built once and reused — not recreated per request. See Best Practices below.


Simple API vs Validator API

Both APIs exist for different reasons — and it is not "one is slow, one is fast." Measured directly (3 runs each, Validate on the same struct):

ns/opallocs
Simple API — validator.Validate(&user) (goes through the internal sync.Map cache lookup)~571 ns10
Validator API — direct instance, no lookup~568 ns10

The difference is ~3 ns — statistically noise, not a real cost. A sync.Map.Load keyed by a stable reflect.Type is far cheaper than the actual validation work that follows it, so it doesn't show up as a measurable difference in practice.

What it's actually for: ergonomic parity with playground-style call patterns, for straightforward migration and everyday use. It mirrors the shape of validate.Struct(x) — call the function, get a result, no setup:

user, err := validator.Unmarshal[User](jsonData)
err = validator.Validate(&user)
schema := validator.Schema[User]()

It is a thin wrapper: the first call for a given type builds a *Validator[T] via New[T]() and caches it in a package-level sync.Map; every later call for that type reuses the cached instance. This is not a performance compromise for the common case — see the measured numbers above.


Best Practices

1. Hold your validator at module level, not per-call

New[T]()'s cost (6.70 µs–16.18 µs, see above) is only paid once if you follow this pattern:

// Good - built once at package init, reused for the life of the program
var userValidator = validator.New[User]()

func handleCreateUser(body []byte) (*User, error) {
return userValidator.Unmarshal(body) // reuses the cached field constraints
}
// Bad - pays the full New() cost on every single call
func handleCreateUser(body []byte) (*User, error) {
userValidator := validator.New[User]()
return userValidator.Unmarshal(body)
}

If you only ever call the package-level Simple API functions (validator.Unmarshal[User](...), etc.), you get this same amortization automatically via the internal cache — no module-level variable needed.

2. Use Register() when a framework needs to find your validator by type

Some integrations — the Echo Binder plugin, validator.UnmarshalInto — only have a reflect.Type at runtime, not your module-level variable. Register() makes a validator discoverable that way:

var _ = validator.Register(validator.New[User]())

Register[T] may be called exactly once per type — a second call for the same type panics, by design. A type could have multiple differently-configured validators (different Options), and Pedantigo has no way to guess which one a framework plugin should resolve to, so it refuses to silently pick one:

var _ = validator.Register(validator.New[User]())
var _ = validator.Register(validator.New[User]()) // panics: already registered

Call Register from exactly one package-level var declaration per type. If a type doesn't need framework lookup, plain New() plus a module-level variable (pattern 1 above) is enough — Register is not required just to get caching.

3. Cache schemas the same way

Schema(), SchemaOpenAPI(), and their JSON variants are cached internally after the first call per validator instance, but if you serve the same schema repeatedly (e.g. an HTTP endpoint), avoid the repeated cache-hit call entirely by storing the result once:

var userValidator = validator.New[User]()
var userSchema = userValidator.Schema() // computed once at init

func sendSchema(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(userSchema)
}

4. Profile before optimizing further

The gap between the Simple API and Validator API is noise for the common case (see above) — don't switch to the Validator API purely for speed unless you've actually measured a hot path where it matters. Do switch if you need custom Options or framework registration, regardless of speed.

go test -cpuprofile=cpu.prof -bench=.
go tool pprof cpu.prof

Further Reading