Skip to main content

Performance

Why zero allocation on the valid path

ZeroAlloc.Validation achieves zero allocation on the valid path through a lazy-allocation pattern driven by the source generator.

At compile time, the generator knows exactly how many rules exist for a given validator. It uses this information to emit a failure buffer with the following behaviour:

  • Valid path: The buffer is never created. No rule is violated, so no buffer is allocated, and the result returns Array.Empty<ValidationFailure>() — a static singleton with no heap cost.
  • Invalid path: A fixed-size array sized to the total number of rules is allocated exactly once, then trimmed to match the actual number of failures.

Because the generator resolves the rule count statically, there is no need for dynamic sizing or reallocation at runtime.

Contrast with FluentValidation, which allocates on every call regardless of whether validation passes:

  • An internal List<ValidationFailure> is created on every invocation.
  • Expression tree delegates are compiled and cached on first use, incurring upfront allocation.
  • Value types are boxed when passed through generic pipelines.
  • Various dictionary and collection structures are used internally for error accumulation.

These costs are unavoidable in FluentValidation's architecture because it has no compile-time knowledge of rule counts or model shapes.

Benchmark environment

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26200.8037)
.NET SDK 10.0.104
[Host] : .NET 10.0.4 (10.0.426.12010), X64 RyuJIT AVX2
DefaultJob : .NET 10.0.4 (10.0.426.12010), X64 RyuJIT AVX2

Benchmark results — Flat model

Three directly validated properties: [NotEmpty][MaxLength(50)], [GreaterThan(0)], [NotEmpty][EmailAddress].

MethodMeanErrorStdDevRatioAllocatedAlloc Ratio
ZA_Valid6.713 ns0.4350 ns1.255 ns0.02-0.00
ZA_Invalid44.012 ns2.7703 ns7.859 ns0.14304 B0.46
FV_Valid327.269 ns10.4974 ns29.436 ns1.01664 B1.00
FV_Invalid2,462.893 ns75.0023 ns210.315 ns7.585408 B8.14

Valid path: ZeroAlloc is ~49x faster and allocates 0 bytes (vs 664 B). Invalid path: ZeroAlloc is ~56x faster and allocates ~18x less (304 B vs 5,408 B).

Benchmark results — Nested model

One scalar property plus a required nested object (3 child properties).

MethodMeanErrorStdDevRatioAllocatedAlloc Ratio
ZA_Valid10.09 ns0.890 ns2.526 ns0.02-0.00
ZA_Invalid96.56 ns2.411 ns6.841 ns0.16608 B0.41
FV_Valid619.14 ns12.334 ns32.707 ns1.001488 B1.00
FV_Invalid2,974.10 ns99.618 ns280.974 ns4.826328 B4.25

Valid path: ZeroAlloc is ~61x faster and allocates 0 bytes (vs 1,488 B). Invalid path: ZeroAlloc is ~31x faster and allocates ~10x less (608 B vs 6,328 B).

Benchmark results — Collection model

A cart with a string ID and a list of three line items.

MethodMeanErrorStdDevRatioAllocatedAlloc Ratio
ZA_Valid14.30 ns0.377 ns1.050 ns0.007-0.00
ZA_Invalid178.54 ns6.361 ns18.044 ns0.089856 B0.25
FV_Valid2,042.95 ns89.644 ns254.305 ns1.0143456 B1.00
FV_Invalid5,957.29 ns249.827 ns704.642 ns2.95811568 B3.35

Valid path: ZeroAlloc is ~143x faster and allocates 0 bytes (vs 3,456 B). Invalid path: ZeroAlloc is ~33x faster and allocates ~14x less (856 B vs 11,568 B).

Summary

ScenarioZA validFV validSpeedupZA alloc (valid)FV alloc (valid)
Flat6.7 ns327 ns~49x0 B664 B
Nested10.1 ns619 ns~61x0 B1,488 B
Collection14.3 ns2,043 ns~143x0 B3,456 B

Running the benchmarks yourself

# From repo root
dotnet build benchmarks/ZeroAlloc.Validation.Benchmarks -c Release --no-incremental
cd benchmarks/ZeroAlloc.Validation.Benchmarks
dotnet run -c Release -- --filter '*' --job Default

Note: --no-incremental is required because the source generator's output assembly is excluded from MSBuild's incremental-build inputs. Without it, a change to the generator may not be detected and stale generated code may be benchmarked.