Back to Blog
C#

Using C# Default Parameters Safely

c# default parameters: Understand C# default parameters: compile-time behavior, optional argument rules, interaction with named arguments, and implications for API des...

optional parametersmethod overloadingnamed argumentsC# syntaxAPI design
C# method signature with an optional parameter and a default value, highlighting how the default is baked into the call site.

C# default parameters let a method declare a default value for a parameter, allowing callers to omit that argument. The compiler substitutes the default value at the call site, which is a key detail that affects how you should design and maintain public APIs. This article explains the syntax, the compile-time behavior, the interaction with named and optional arguments, and the practical tradeoffs of using default parameters versus method overloading.

How Default Parameters Work in C#

A default parameter is declared by assigning a constant expression to the parameter in the method signature:

public void Log(string message, int level = 1) { Console.WriteLine($"[{level}] {message}"); }

Callers can invoke Log("start") or Log("start", 2). When the argument is omitted, the compiler inserts the literal constant 1 as the argument at the call site. This means the default value is baked into the caller's compiled code, not resolved at runtime. The parameter is said to be optional; the default value is just a convenient shorthand for the caller.

The default value must be a compile-time constant. That includes numeric literals, string literals, null, default (for value types), and const fields. You cannot use a non-constant expression, such as a static property or a value read from a configuration file, as a default parameter value.

Syntax Rules for Optional Parameters

Optional parameters must appear after all required parameters in the method declaration. The following compiles:

public void Configure(string host, int port = 80, bool useTls = true) { }

The following does not compile because a required parameter follows an optional one:

public void Configure(int port = 80, bool useTls = true, string host) { } // error

When calling a method that has multiple optional parameters, you can omit trailing arguments, but you cannot skip an optional parameter in the middle unless you use a named argument. For example, given the Configure method above, this call is valid:

Configure("localhost", useTls: false);

That call provides host positionally and useTls by name, skipping port to use its default value. Without the named argument, you would have to pass port explicitly, as in Configure("localhost", 443, false).

Interaction with Named Arguments

Named arguments and default parameters work together well. A named argument can refer to a parameter that has a default value, and the call can omit other optional parameters that come earlier in the parameter list. For example:

Configure(host: "example.com", useTls: true);

This call skips port entirely, even though it appears before useTls in the signature. Named arguments make the call site explicit about which optional argument is being provided, improving readability when a method has several optional parameters.

However, once you use a named argument, you cannot follow it with a positional argument in the same call. The C# compiler enforces that positional arguments must appear before named arguments. So Configure("example.com", useTls: true) is valid, but Configure(useTls: true, "example.com") is not.

Default Parameters and Method Overloads

Before optional parameters were introduced (in C# 4.0), developers often created multiple overloads to emulate optional arguments. Both approaches can produce the same call-site behavior, but they differ in important ways.

Consider a method that accepts an optional timeout:

public void Fetch(string url, int timeoutMs = 30000) { // implementation }

The equivalent overloaded version would be:

public void Fetch(string url) { Fetch(url, 30000); } public void Fetch(string url, int timeoutMs) { // implementation }

Overloads give you more control: you can change the behavior of the one-argument version without affecting the two-argument version, and you can choose different default values for different overload chains. Overloads also allow the default value to change without recompiling callers, because the one-argument overload still calls the two-argument version with a new value — but that is only true if the overload is defined in the same assembly and the caller recompiles against the new version.

Default parameters, on the other hand, reduce boilerplate and make the API surface smaller. With a single method, the signature shows the default values directly, which can be helpful documentation. But the default value is locked into the caller's assembly, so changing the default in the library does not affect callers that were compiled against the old default. This is a critical compatibility concern.

If you intend to change the default value in the future, an overloaded approach (or a method that reads a default from a configuration source) is safer. If the default is stable and you prioritize a concise API, default parameters are a reasonable choice.

What Happens When a Default Value Is Omitted?

When a caller omits an optional argument, the compiler emits IL that passes the constant value directly. There is no runtime dispatch that consults the method's metadata to fill in the default. This means the default value is not read from the called method's definition at runtime; it is compiled into the caller.

For example, suppose you have a library Lib.dll that declares:

public static void Print(string text, bool uppercase = false)

An application compiled against version 1 of Lib.dll calls Print("hello"). The compiler emits Print("hello", false). If you later ship version 2 of Lib.dll with bool uppercase = true, the already-compiled application will still pass false because that value is baked into its own code. Only applications recompiled against version 2 will use true. This behavior is often surprising, especially for developers who assume the default is evaluated at runtime.

The same applies to optional parameters in interfaces and abstract methods. The default value declared in an interface is not used by implementations; each implementation declares its own default, and the compiler uses the default from the static type of the variable through which the call is made. This can lead to different behavior depending on whether you call through an interface reference or a concrete class reference.

Default Parameters in Interfaces and Overrides

When an interface declares an optional parameter, the default value is not inherited by implementing classes or overridden methods. The compiler uses the default from the static type of the reference on which the method is called.

Consider:

interface ILogger { void Log(string message, int level = 1); } class FileLogger : ILogger { public void Log(string message, int level = 2) { // write to file } } var logger = new FileLogger(); logger.Log("info"); // level = 2 ILogger interfaceLogger = new FileLogger(); interfaceLogger.Log("info"); // level = 1

The ambiguity can cause subtle bugs. The recommended approach for interfaces and virtual methods is to avoid default parameters altogether, or to document that callers should rely on explicit arguments.

Maintainability and API Design Tradeoffs

Using default parameters can make method signatures concise, but it also introduces hidden coupling between the caller and the default values. When you change a default, the change does not propagate to existing callers. This can break the principle of least surprise and create bugs that are hard to trace.

For internal code — such as private or internal methods within the same assembly — default parameters are usually safe and convenient. The compiler and the developer can see the default value, and because the method is not part of a public contract, changing it does not silently affect external consumers.

For public APIs, overloads are often the better choice. They let you evolve each signature independently and avoid baking defaults into consumer assemblies. If you must use default parameters in a public API, treat the defaults as frozen and plan to release a new overload if you need to change them.

Another consideration is readability at the call site. Named arguments mitigate this, but a method with many optional parameters can make the signature itself difficult to read. If a method has more than two or three optional parameters, reconsider whether a parameter object or a fluent builder pattern would produce a clearer API.

Runtime Cost and Reflection Considerations

Default parameters have essentially no runtime cost. The compiler emits the constant as a normal argument, so there is no extra dispatch, parsing, or lookup. The only overhead is the slight increase in IL size for the longer call.

Reflection, however, exposes default parameter values differently. The ParameterInfo class has a DefaultValue property that returns the default if one is declared. This can be useful for tools that inspect APIs, but be aware that reflection sees the metadata default, not the compiler-inserted call-site default. This distinction is rarely a problem in practice, but if you build code generators or analyzers, you should account for it.

You can also use reflection to invoke a method without providing optional arguments, but you must pass Type.Missing or the appropriate Optional value for each omitted parameter. This is a niche use case and should not be part of everyday development, but it is relevant for dynamic invocation scenarios.

c# default parameters: Practical Usage and Code Examples | RYUSLOG DEV