- Published on
FluentValidation vs .NET 10 Built-In Validation: Performance Benchmark
- Authors
- Name
- Ivan Gechev
We've been adding FluentValidation to every .NET project by default for years. It's a great library, battle-tested and flexible, and for a long time it was the most sensible choice for request validation in ASP.NET Core Minimal APIs.
But .NET 10 ships with first-class, built-in validation support for Minimal APIs. Zero extra dependencies, tight framework integration, and the same data annotations approach we already know. So the obvious question is: does the convenience come with a performance cost?
To find out, I benchmarked FluentValidation vs. .NET 10 built-in validation head-to-head across 1,000 requests — both sequential and parallel, valid and invalid payloads. In this article, I'll walk through the benchmark setup, the results, and what they actually mean for how to approach validation going forward.
Let's dive in!
How I Set Up the FluentValidation vs .NET 10 Benchmark
Before we look at the numbers, let's be clear about what I'm actually measuring. Both approaches were tested against the same cat API endpoint, with equivalent validation rules. I used WebApplicationFactory to spin up a real in-process test server, so I'm measuring end-to-end request handling — not just the validation logic in isolation.
Here's how I structured the benchmark:
- 1,000 requests per scenario
- Sequential scenario: requests sent one after another
- Parallel scenario: requests batched in groups of 100
- Both valid and invalid payloads tested separately
- Warm-up requests sent before each measurement to eliminate cold start noise
The .NET 10 Built-In Validation Endpoint
app.MapPost("/i-validatable-object", (
[FromBody] IValidatableObjectCreateCatRequest request) =>
{
return Results.Ok(new CatResponse(request.Name, request.Breed, request.AgeInMonths));
});
public record IValidatableObjectCreateCatRequest : IValidatableObject
{
[Required, Length(2, 25)]
public required string Name { get; init; }
[Required, Length(2, 20, ErrorMessage = $"{nameof(Breed)} must be between 2 and 20 characters")]
public required string Breed { get; init; }
[Required]
public required int AgeInMonths { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (AgeInMonths < 1 || AgeInMonths > 240)
{
yield return new ValidationResult(
$"{nameof(AgeInMonths)} must be between 1 and 240, you provided: {AgeInMonths}",
[nameof(AgeInMonths)]);
}
}
}
The handler itself is one line. All validation logic lives in the model — data annotations for the simple rules, and IValidatableObject for the range check that needs a custom error message. The framework validates automatically before the handler even runs.
The FluentValidation Endpoint
app.MapPost("/fluent-validation", (
[FromBody] FluentCreateCatRequest request,
[FromServices] IValidator<FluentCreateCatRequest> validator) =>
{
var validationResult = validator.Validate(request);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(
validationResult.ToDictionary(),
statusCode: StatusCodes.Status422UnprocessableEntity);
}
return Results.Ok(new CatResponse(request.Name, request.Breed, request.AgeInMonths));
});
public record FluentCreateCatRequest(string Name, string Breed, int AgeInMonths);
public class FluentCreateCatRequestValidator : AbstractValidator<FluentCreateCatRequest>
{
public FluentCreateCatRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty()
.Length(2, 25);
RuleFor(x => x.Breed)
.NotEmpty()
.Length(2, 20)
.WithMessage($"{nameof(FluentCreateCatRequest.Breed)} must be between 2 and 20 characters");
RuleFor(x => x.AgeInMonths)
.InclusiveBetween(1, 240)
.WithMessage($"{nameof(FluentCreateCatRequest.AgeInMonths)} must be between 1 and 240");
}
}
The FluentValidation endpoint needs the validator injected, run manually, errors mapped to the right format, and the appropriate status code returned. The rules are equivalent — same constraints, same error messages — so I'm benchmarking pipeline overhead, not validation complexity.
The BenchmarkDotNet Runner
[RankColumn]
[Config(typeof(StyleConfig))]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class ApiBenchmark
{
private WebApplicationFactory<Validation.Api.Program> factory;
private HttpClient client;
private CreateCatRequest validRequest;
private CreateCatRequest invalidRequest;
private const int TotalRequests = 1_000;
private const int BatchSize = 100;
[GlobalSetup]
public void Setup()
{
factory = new WebApplicationFactory<Validation.Api.Program>();
client = factory.CreateClient();
validRequest = new CreateCatRequest("Whiskers", "Siamese", 12);
invalidRequest = new CreateCatRequest("", "", 0);
// Warmup
client.PostAsJsonAsync("/fluent-validation", validRequest).GetAwaiter().GetResult();
client.PostAsJsonAsync("/i-validatable-object", validRequest).GetAwaiter().GetResult();
}
[Benchmark(Baseline = true, Description = ".NET 10 Validation - Sequential Valid")]
[BenchmarkCategory("Sequential Valid")]
public async Task IValidatableObjectEndpoint_Sequential()
{
for (int i = 0; i < TotalRequests; i++)
{
var response = await client.PostAsJsonAsync("/i-validatable-object", validRequest);
response.EnsureSuccessStatusCode();
}
}
// ... parallel benchmarks follow the same pattern
}
Let's walk through what each attribute is doing here, because these choices directly affect how readable and trustworthy the results are.
[RankColumn] adds a Rank column to the results table. Each benchmark gets a position (1st, 2nd, etc.) within its category, so it's immediately obvious which approach is faster without having to mentally compare millisecond values.
[Config(typeof(StyleConfig))] points to the custom StyleConfig class, which sets RatioStyle.Trend. By default, BenchmarkDotNet shows ratios as plain numbers. With Trend, we get a direction indicator — so instead of 1.38, we see 1.38x slower, which reads much more naturally in a results table.
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] is what keeps the results table sane. Without it, all eight benchmarks show up as one flat list and comparing sequential vs. parallel becomes a mess. With it, BenchmarkDotNet groups by the [BenchmarkCategory] we set on each method — sequential valid, sequential invalid, parallel valid, and parallel invalid each get their own section.
[Orderer(SummaryOrderPolicy.FastestToSlowest)] sorts results within each group by execution time, so the winner always appears first. Combined with the rank column, the summary table is easy to scan at a glance.
[Benchmark(Baseline = true)] marks the .NET 10 built-in validation as the reference point for ratio calculations. The ratio column then shows how FluentValidation compares to it — anything above 1.0 means FluentValidation took longer by that factor.
[GlobalSetup] runs once before all benchmark iterations, not before each one. I want to create WebApplicationFactory and HttpClient a single time and reuse them across all iterations. Creating them per-iteration would just measure setup overhead, not validation performance.
I ran each scenario multiple times and took the average to reduce variance from background noise.
If you are interested in having a deep dive into Minimal APIs, you can check my Minimal APIs in ASP.NET Core course here.
FluentValidation vs .NET 10 Validation: Benchmark Results
Here's where things get interesting.
Sequential Requests (1,000 total)
| Method | Mean | Ratio | Rank |
|---|---|---|---|
| .NET 10 Validation - Sequential Valid | 50.08 ms | baseline | 1 |
| FluentValidation - Sequential Valid | 51.86 ms | 1.04x slower | 2 |
| .NET 10 Validation - Sequential Invalid | 51.71 ms | baseline | 1 |
| FluentValidation - Sequential Invalid | 64.14 ms | 1.24x slower | 2 |
The sequential results tell two different stories. On valid payloads, the difference is almost negligible — 50.08 ms vs. 51.86 ms, a 1.04x gap that's well within noise range for most applications. On invalid payloads though, the gap widens to 1.24x. FluentValidation still has to resolve the validator from the DI container, run the rules, and map errors to a response format even when the request is obviously bad. The .NET 10 built-in validation skips all that and returns a ProblemDetails response without the extra overhead.
Parallel Requests (batches of 100)
| Method | Mean | Ratio | Rank |
|---|---|---|---|
| FluentValidation - Parallel Valid | 29.71 ms | 1.03x faster | 1 |
| .NET 10 Validation - Parallel Valid | 30.38 ms | baseline | 2 |
| .NET 10 Validation - Parallel Invalid | 32.65 ms | baseline | 1 |
| FluentValidation - Parallel Invalid | 43.15 ms | 1.33x slower | 2 |
The parallel results are the most interesting part. On valid payloads, FluentValidation is actually marginally faster — 29.71 ms vs. 30.38 ms. The 1.03x difference is noise, but it's worth being honest about rather than cherry-picking numbers that fit the narrative.
On invalid parallel payloads, the .NET 10 built-in validation wins more clearly at 1.33x, for the same reasons as the sequential case — less pipeline work when rejecting bad requests.
What These Numbers Actually Mean
The honest takeaway is more nuanced than "built-in validation is always faster." On valid requests, the two approaches are effectively the same. The real performance difference shows up on invalid requests — 1.24x slower sequentially, 1.33x slower in parallel.
Beyond raw performance, the built-in approach has zero additional dependencies. No NuGet package to update, no version mismatches, no extra abstractions to learn. For straightforward validation cases, that simplicity matters beyond just the numbers.
When to Use FluentValidation Over .NET 10 Built-In Validation
The benchmark tells us built-in validation is faster on bad requests, but faster doesn't always mean the right call for our specific use case.
FluentValidation is still the right tool when:
- We need async validators — checking whether an email is already taken requires a database query during validation. The .NET 10 built-in validation doesn't support async rules.
- We have deeply complex conditional rules —
FluentValidation's rule chaining andWhen/Unlessconditions are far more expressive than data annotations for tricky business logic. - We're on .NET 9 or earlier — the built-in Minimal API validation feature is .NET 10-specific. There's no backport.
- We already have a large
FluentValidationinvestment — migrating a mature codebase with dozens of validators isn't worth it just for a performance gain on invalid requests.
If our validation needs are covered by data annotations and IValidatableObject for custom logic, there's no good reason to pull in an extra dependency.
Returning 422 Instead of 400 for Validation Errors in .NET 10
By default, .NET 10's built-in validation returns 400 Bad Request when validation fails. That's not quite right. According to RFC specs, 400 means the server can't process the request due to malformed syntax or deceptive routing. Validation errors are different — the request is well-formed, but the data inside doesn't meet our business rules.
This is where 422 Unprocessable Entity comes in. RFC 4918 defines it specifically for cases where the syntax is correct but the server couldn't process the contained instructions — which is exactly what a validation failure is. The JSON is valid, the data just doesn't meet our requirements.
We can fix this by customizing the problem details service in Program.cs:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
if (context.ProblemDetails.Status == (int)HttpStatusCode.BadRequest &&
context.ProblemDetails is HttpValidationProblemDetails validationProblem)
{
context.ProblemDetails.Status = StatusCodes.Status422UnprocessableEntity;
context.ProblemDetails.Type = "https://tools.ietf.org/html/rfc4918#section-11.2";
}
};
});
Inside the CustomizeProblemDetails callback, we check if the response is 400 Bad Request and use pattern matching to verify the ProblemDetails object is HttpValidationProblemDetails — that's what tells us we have at least one validation error. If both conditions are true, we update the status code to 422 and point the type field at the correct RFC.
Now when validation fails, our API returns:
{
"type": "https://tools.ietf.org/html/rfc4918#section-11.2",
"title": "One or more validation errors occurred.",
"status": 422,
"errors": {
"Name": [
"The Name field is required."
],
"Breed": [
"Breed must be between 2 and 20 characters"
]
},
"traceId": "00-0b2fb9125fc5a6ce1cef4f1cb683e1cb-b8bc575bda84c880-00"
}
One thing worth knowing: the Validate() method from IValidatableObject only runs after all data annotation validations have passed. So if multiple attribute-level rules fail, the custom validation logic won't execute yet — you'll only see annotation errors in the response until those are fixed.
IProblemDetailsService gives us full control over error responses beyond just the status code. We can add custom fields, include trace identifiers for debugging, or hook into logging and monitoring systems.
If you want to dig deeper into how .NET 10 validation works end-to-end — route parameters, request bodies, IValidatableObject — check out our full guide on validating incoming requests in .NET 10 Minimal APIs.
Conclusion
I've been defaulting to FluentValidation for a long time, and that made sense before .NET 10. But now that the framework ships with fast, dependency-free, built-in validation for ASP.NET Core Minimal APIs, it's my first choice for straightforward request validation.
The FluentValidation vs. .NET 10 benchmark results are clear where it matters: built-in validation is faster on invalid requests — 1.24x sequentially, 1.33x in parallel. On valid requests, it's essentially a tie. FluentValidation even sneaks ahead by 1.03x on parallel valid payloads, though that's well within noise. For async validators, complex conditional rules, or existing FluentValidation-heavy projects, the library still has a place.
But if you're starting a new .NET 10 Minimal API project with standard validation needs, built-in is the leaner, faster, and simpler choice.
Frequently Asked Questions
Is .NET 10 built-in validation faster than FluentValidation?
On invalid requests, yes — up to 1.33x faster in parallel scenarios and 1.24x faster sequentially. On valid requests, the two are effectively the same, with FluentValidation even marginally ahead (1.03x) in parallel valid scenarios, which is within noise range.
Should I replace FluentValidation with .NET 10 built-in validation?
It depends. If your validation rules are straightforward — required fields, length constraints, range checks — the built-in validation is the simpler choice with zero extra dependencies. If you need async validators, complex conditional logic, or you're on .NET 9 or earlier, stick with FluentValidation.
Does .NET 10 built-in validation work with Minimal APIs?
Yes. .NET 10 introduces first-class validation support specifically for Minimal APIs via builder.Services.AddValidation(). It validates incoming requests automatically using data annotations and IValidatableObject before the endpoint handler executes.
What status code does .NET 10 validation return for validation errors?
By default, 400 Bad Request. This can be overridden to return 422 Unprocessable Entity through a custom ValidationResultFactory, which is the more semantically correct status code per RFC 9110.