Route constraints restrict which URL segments a route template matches — they are the difference between a route matching /posts/42 (integer) vs /posts/my-slug (string). Without constraints, the first matching route wins regardless of segment content, which can cause one route to shadow another. Defaults provide fallback values when segments are absent. Together they give you fine-grained control over route matching, enabling slug-based and ID-based routes for the same resource, version-based routes, and locale-aware URLs.
Built-In Constraints
// ── Integer constraints ───────────────────────────────────────────────────
[HttpGet("{id:int}")] // matches only integers: /posts/42
[HttpGet("{id:long}")] // long integer: /posts/99999999999
[HttpGet("{id:min(1)}")] // integer >= 1
[HttpGet("{id:max(1000)}")] // integer <= 1000
[HttpGet("{id:range(1,1000)}")] // 1 <= integer <= 1000
// ── String constraints ────────────────────────────────────────────────────
[HttpGet("{slug:alpha}")] // letters only: /posts/hello
[HttpGet("{slug:minlength(3)}")] // at least 3 chars
[HttpGet("{slug:maxlength(100)}")] // at most 100 chars
[HttpGet("{slug:length(3,100)}")] // between 3 and 100 chars
[HttpGet("{slug:regex(^[a-z0-9-]+$)}")] // matches regex pattern
// ── Type constraints ──────────────────────────────────────────────────────
[HttpGet("{id:guid}")] // GUID: /users/3fa85f64-5717-4562-b3fc-2c963f66afa6
[HttpGet("{id:bool}")] // true or false
[HttpGet("{date:datetime}")] // parseable DateTime
// ── Coexisting ID and slug routes ─────────────────────────────────────────
// Because constraints are specific, both can coexist without conflict
[Route("posts")]
public class PostsController : Controller
{
// ID route — matches only integers
[HttpGet("{id:int}")] // GET /posts/42
public async Task<IActionResult> ById(int id) => View(await _svc.GetByIdAsync(id));
// Slug route — matches only lowercase slug pattern
[HttpGet("{slug:regex(^[a-z0-9-]+$)}")] // GET /posts/my-article-title
public async Task<IActionResult> BySlug(string slug) => View(await _svc.GetBySlugAsync(slug));
// Without constraints both routes would match /posts/42 — conflict!
}
{id:int}, {id:range(1,100)}. Multiple constraints can be chained: {id:int:min(1):max(1000)} requires an integer between 1 and 1000. Constraints run in order — the first failing constraint immediately rejects the route without checking subsequent ones. Constraints are evaluated during route matching (before the action is called), so a failed constraint causes the router to try the next matching route rather than returning 400.regex constraint for slug validation in routes: {slug:regex(^[a-z0-9-]+$)}. This ensures that requests for slugs containing uppercase letters, spaces, or special characters do not match this route (and either 404 or match a different route). However, remember that route constraints are for disambiguation, not security validation — always validate the actual value in the service layer as well.IRouteConstraint) run on every request that reaches the routing middleware. A custom constraint that makes database calls runs a database query for every single incoming HTTP request — even for requests that do not match that controller. Keep custom constraints extremely lightweight (pure computation, no I/O). Use the action method or a filter for business-rule validation that requires database access.Custom Route Constraint
// ── Custom IRouteConstraint — runs during route matching ──────────────────
public class YearConstraint : IRouteConstraint
{
public bool Match(
HttpContext? httpContext,
IRouter? route,
string routeKey,
RouteValueDictionary values,
RouteDirection routeDirection)
{
if (!values.TryGetValue(routeKey, out var value))
return false;
if (!int.TryParse(value?.ToString(), out int year))
return false;
// Only match years 2000–2099
return year is >= 2000 and <= 2099;
}
}
// ── Register the constraint ───────────────────────────────────────────────
builder.Services.Configure<RouteOptions>(opts =>
opts.ConstraintMap["year"] = typeof(YearConstraint));
// ── Use in route template ─────────────────────────────────────────────────
[HttpGet("{year:year}/{slug}")] // only matches if year is 2000–2099
public async Task<IActionResult> Post(int year, string slug) => View();
Default Route Values
// ── Inline defaults in template ────────────────────────────────────────────
app.MapControllerRoute(
name: "paged",
pattern: "posts/{page=1}"); // page defaults to 1 if absent
// ── Optional segment ──────────────────────────────────────────────────────
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}"); // id is optional
// ── Catch-all parameter — matches everything including slashes ─────────────
[HttpGet("files/{**path}")] // matches /files/docs/2025/report.pdf
public IActionResult GetFile(string path) => PhysicalFile(path, "application/octet-stream");
Common Mistakes
Mistake 1 — Ambiguous routes without constraints (both routes match the same URL)
❌ Wrong — two routes match /posts/42; framework throws AmbiguousMatchException:
[HttpGet("{id}")] // matches /posts/42 (string "42")
[HttpGet("{slug}")] // also matches /posts/42 — ambiguous!
✅ Correct — add constraints: {id:int} and {slug:regex(^[a-z-]+$)}.
Mistake 2 — Database call inside a custom IRouteConstraint (N+1 on every request)
❌ Wrong — constraint checks database for every incoming request.
✅ Correct — constraints must be pure computation only; validate data in actions or filters.