Updated on

A route parameter is optional when its name carries a question mark in the route template. [HttpGet("GetById/{id?}")] matches /GetById and /GetById/5 alike, and the action’s own parameter default decides what id holds when the segment is not there.

Those are two separate mechanisms doing two separate jobs, and the route template has a third form for the case where they are usually confused. The sections below take them one at a time: what the ? does, how a template default differs from it, and how both behave once a route constraint is attached.

To download the source code for this article, you can visit our GitHub repository.

What Makes a Route Parameter Optional in ASP.NET Core?

A route parameter becomes optional when we append a question mark to its name inside the route template. [HttpGet("GetById/{id?}")] matches both /GetById and /GetById/5.

The question mark changes routing, not binding. It tells the router the segment may be missing and the route still matches. What the action’s parameter holds when it is missing is a separate decision, made in the method signature.

The two usually travel together, but nothing forces it. Without a default, /GetById still matches and id silently arrives as 0. Writing public WeatherForecast GetById(int id = 1) supplies the value the URL did not.

Order matters in the template. Optional route parameters go after all required parameters and literals: in {id?}/{color} the question mark is ignored and the segment stays required.

An optional route segment is not the same thing as an optional query string value. A simple action parameter the template does not name binds from the query string instead, where a nullable type or a C# default already makes it optional.

[HttpGet("GetById/{id?}")]
public WeatherForecast GetById(int id = 1)
{
    var forecasts = Get();

    var weatherForecast = forecasts.Where(w => w.Id == id).FirstOrDefault()!;

    return weatherForecast;
}

In this case, we assign 1 as a default value for the id parameter in the GetById action method. When we call either /api/WeatherForecast/GetById or /api/WeatherForecast/GetById/1, we get the same result.

Also, as we can see, we are using attribute routing to provide an optional parameter. If you are not familiar with attribute routing in ASP.NET Core, you can check out our Routing in ASP NET Core article for more information.

Optional parameters in a route template are a different mechanism from optional parameters in a C# method signature, which have their own rules and apply to any method, not only to an action.

The query string is the other half of the story. A parameter the template never names is bound from the query string instead, and reading values from the query string follows rules of its own.

What Is the Difference Between an Optional Parameter and a Default Value?

A default value always produces a value. An optional parameter only has a value when the request URL supplies one. Microsoft’s routing reference draws the line in exactly those terms.

{id?} makes the segment skippable and leaves id unset when it is skipped. {id=1} also makes the segment skippable, but the router substitutes 1 before the action ever runs, so id is never unset.

The practical difference is what the action can observe. With {id=1} a method cannot tell a request for /GetById from a request for /GetById/1 through its parameters, because both arrive carrying the same route value. With {id?} and a nullable parameter such as int? id, a missing segment arrives as null, and the method can branch on it.

The form this article uses sits between the two. The router leaves the value unset and the C# default fills it in, which looks like a default value to the caller and like an optional segment to the router.

Template segmentMatches a URL without the segment?Route value when the segment is absentNotes
{id}Non/aRequired. /GetById does not match at all
{id?}YesNot setThe action's own parameter default supplies the value
{id=1}Yes1The router substitutes the default before the action runs
{id:int}Non/aRequired, and must parse as an integer
{id:int?}YesNot setConstraint first, ? last. Still refuses a non-integer
{id:int=1}Yes1Constraint and default combined
{*slug}YesPresent, value nullCatch-all: binds the rest of the URI, and matches the empty string
{**slug}YesPresent, value nullSame, and round-trips / when the URL is generated back

The figure below lines up the three templates the table’s first rows describe. Look at the middle row against the bottom one: both match a URL with no id, but only one of them hands the action a value the router chose.

Three ASP.NET Core route templates compared: a required id segment, an optional id segment, and an id segment with a default value, showing which request URLs match each and what value the action receives.

The mirror question comes up as soon as this one is settled, and it has its own answer: making a query string parameter required.

How Do We Combine Route Constraints With Optional Parameters?

We put the constraint first and the question mark last. {id:int?} reads as “an integer, and it may be absent”.

A constraint narrows which URLs a template matches. {id:int} matches /GetBy/5 and refuses /GetBy/boots, which is what lets two actions share the GetBy literal and be told apart by the shape of the segment rather than by their method names.

Adding ? relaxes the presence requirement and nothing else. {id:int?} still refuses /GetById/boots. It simply also matches /GetById.

Constraints match, they do not validate. A URL that fails a constraint does not reach the action with an invalid model state, because it never matched the route in the first place, and the caller sees a 404 rather than a 400.

The ordering rule from earlier still applies here. A constrained optional segment is still an optional segment, so it goes after every required segment and literal in the template.

[HttpGet("GetBy/{name}")]
public Product GetBy(string name)
{
    var products = Get();

    return products.Where(p => p.Name == name).FirstOrDefault()!;
}

[HttpGet("GetBy/{id:int}")]
public Product GetBy(int id)
{
    var products = Get();

    return products.Where(p => p.Id == id).FirstOrDefault()!;
}

Here, we have two different GetBy actions, where the second action has a route with the int route constraint applied. This means we can only access GetBy(int id) action when we pass an integer as a parameter to it.

Now, if we want to use optional parameters with route constraints, we can simply specify a default parameter in the action method:

[HttpGet("GetById/{id:int?}")]
public Product GetById(int id = 1)
{
    var products = Get();

    return products.Where(p => p.Id == id).FirstOrDefault()!;
}

Now, we apply the int constraint to our action but also, we make it optional and we don’t have to pass it in our request. By default, the value of the id parameter will be 1.

What Replaces UrlParameter.Optional in ASP.NET Core?

Nothing replaces it, because ASP.NET Core does not need it. UrlParameter.Optional belongs to System.Web.Mvc on .NET Framework, and the inline ? in a route template does the same job with no sentinel object involved.

In ASP.NET MVC 5, the sentinel was supplied as a route default, and MvcHandler.RemoveOptionalRoutingParameters stripped every route value equal to it before the controller was even created, so an omitted segment left no key behind for a value provider to find. That removal step is why the parameter appears to be ignored everywhere it is looked for.

Do not carry the pattern forward. There is no sentinel to place, no sentinel to strip, and nothing to compare a route value against.

The migration is mechanical. A conventional route ending in a default of UrlParameter.Optional becomes a template ending in {id?}, and an action that tested a route value against the sentinel becomes an action with a nullable parameter or a C# default.

Conclusion

In this article, we learned about optional parameters and how we can use them in ASP.NET Core Web API attribute routing. The question mark decides whether the URL matches without the segment, the method signature or a template default decides what the action receives, and a route constraint narrows which URLs match at all.

For the wider picture of getting data into an action, our article on passing parameters with a GET request covers the route, query string and body together.

Tested with .NET 10.0.10.