Last Updated: May 22, 2026
Method overloading is C#'s way of letting one method name handle several shapes of input. Calculating a discount for a single product, calculating it for a list of cart items, and calculating it with a custom percentage are three variations of the same idea, so they deserve the same name. The compiler decides which version runs based on the arguments at the call site, not on the variable name. This chapter walks through the rules for declaring overloads, how the compiler picks between them, and the traps that make seemingly innocent calls fail to compile.
A poorly designed API forces callers to remember a different name for every variation of an operation. ApplyTenPercentDiscount, ApplyCustomDiscount, ApplyDiscountToList. The names mean the same thing semantically, but the callers have to keep the variations straight. Overloading collapses the family into one name with multiple parameter lists.
Three calls, three different overloads, one method name. The caller writes what they mean; the compiler routes the call to the right body. The same pattern shows up everywhere in the .NET base class library. Console.WriteLine has 19 overloads. string.Format has overloads for one argument, two arguments, three arguments, a params array, and an IFormatProvider. The reader doesn't need to think about which overload is which because the names line up with intent.
Overloading also lets you add convenience versions over a richer core method. A "primary" overload does the real work; the simpler overloads forward to it with sensible defaults.
This pattern keeps the API surface small in the caller's mind, while leaving one method body that holds the real logic. If the discount math changes, you fix it in the primary overload and every caller benefits without touching their code.
The compiler distinguishes overloads by the method's signature, not by its name or return type. The signature is built from:
ref, out, and in (these count as distinguishing features).Anything else, return type, parameter names, the params keyword on the last parameter, default values, does not distinguish overloads on its own.
Valid ways to differ between overloads:
| Difference | Example | Distinguishes? |
|---|---|---|
| Different parameter count | Format(decimal) vs Format(decimal, string) | Yes |
| Different parameter types | Format(decimal) vs Format(int) | Yes |
| Different parameter order (types differ) | Pair(int, string) vs Pair(string, int) | Yes |
ref vs no modifier | Update(int) vs Update(ref int) | Yes |
out vs no modifier | Parse(string) vs Parse(string, out int) | Yes |
| Different return type only | int Get() vs string Get() | No (compile error) |
| Different parameter names only | f(int x) vs f(int y) | No (compile error) |
params only on one of them | f(int[]) vs f(params int[]) | No (compile error) |
| Default value differences only | f(int x) vs f(int x = 0) | No (compile error) |
The two pairs at the top of the "No" group are the ones that bite people most often.
The compiler error is:
The return type isn't part of the signature, so changing it doesn't make the methods different to the compiler. The same goes for parameter names: only types and modifiers matter.
The ref, out, and in modifiers do count, but they have a wrinkle. You can't overload one of them against another. The compiler accepts Method(int x) paired with Method(ref int x), but not Method(ref int x) paired with Method(out int x). The reasoning: ref, out, and in all pass the argument by reference at the IL level; they only differ in C# semantics, and that isn't enough for the runtime to tell them apart.
The presence of ref at the call site selects the second overload. The first call passes by value, leaves the variable alone, and runs the no-modifier body. This is one of the few places where the call-site syntax (ref keyword on the argument) is part of overload resolution.
When you call an overloaded method, the compiler runs an algorithm called overload resolution to pick the best match. It builds a set of candidate overloads (every method with the right name that could accept the arguments), then ranks them and picks a winner. If there is no clear winner, it refuses to compile.
The ranking favors more specific matches over more general ones. From most preferred to least:
The full rule set is more involved, but the ranking above captures most of what comes up. Each step in a worked example:
Walking through it:
Quote(5) matches int exactly. Exact match wins.Quote(5L) matches long exactly.Quote(5.0) matches double exactly.Quote("five") has no exact match. string doesn't implicitly convert to int, long, or double, but it does to object. Only one overload accepts it, so it wins by default.Quote((short)5) has no exact match. short implicitly converts to int, long, double, and object. All four are candidates. int is the "narrowest" target, so it's preferred.That last point is the rule of thumb: when several implicit conversions are available, the compiler picks the smallest, most specific target type. short -> int is a narrower step than short -> double, so int wins.
Overload resolution runs entirely at compile time. There is no runtime overhead for picking an overload, the IL emitted is a direct call to the chosen method. The only way overload resolution costs anything at runtime is when dynamic is involved, which defers resolution until execution.
A more practical example: a FormatPrice family that takes either a raw decimal or a richer Product type.
The compiler picks each overload because the argument types differ. There is no ambiguity, no conversion guesswork, just an exact match on each call.
When the compiler can't decide between two overloads, neither one being strictly better than the other, it stops with CS0121: The call is ambiguous between the following methods. This usually happens when each candidate is better on one argument and worse on another.
The call passes two int literals. The compiler considers both overloads:
| Overload | Argument 1 (5) | Argument 2 (10) |
|---|---|---|
(int, double) | exact match | requires int -> double |
(double, int) | requires int -> double | exact match |
Each overload is better on one argument and worse on the other. Neither one is universally better, so the compiler refuses to pick. The error message is:
The fix is to make the call unambiguous by casting one of the arguments:
Either cast disambiguates the call. The first one makes the second argument an exact match for double, leaving only the first overload as a viable candidate. The second cast does the reverse.
Both overloads accept (int, decimal) because the quantity parameter on the first overload is optional with a default value of 1. The compiler sees two equally valid candidates: the two-parameter overload (exact match) and the three-parameter overload (uses the default for the third).
The tie-breaker rule for this case is "prefer the overload without optional defaults filled in." So actually, this specific case does compile in modern C#: the two-parameter overload wins because no defaults are being applied. But subtle variations of this pattern break, and the cleanest fix is to not pair optional parameters with overloads that share the same shape.
Now there's exactly one overload for each shape. The two-parameter call has only one match, and the three-parameter call has only one match. No tie-breaker is needed, and no surprising behavior creeps in if the rules change.
Both overloading and optional parameters let one method accept several call shapes. They look interchangeable at the call site, but they behave differently and have different costs.
A side-by-side comparison:
| Aspect | Overloading | Optional parameters |
|---|---|---|
| Number of methods to maintain | Two or more bodies | One body |
| Where default value lives | In the convenience overload's body | In the method signature |
| Default value baked into caller's compiled code | No | Yes (this is the big one) |
| Can have different bodies per shape | Yes | No, one body for all calls |
| Works across language boundaries (e.g. F#, VB callers) | Always works | Some languages don't honor optional defaults |
| Reflection sees | Multiple methods | One method with a default |
The "baked into the caller" point is the one to watch. When you write ApplyDiscount(100m) against the optional-parameter version, the C# compiler emits IL that essentially says "call ApplyDiscount(100m, 0.10m)." The default value 0.10m is now sitting inside the caller's compiled DLL.
If your library is shipped as a NuGet package and you later change the default to 0.15m, callers compiled against the old version will still pass 0.10m until they recompile. With overloading, the default lives in your library, so a rebuild of the library is enough to roll out the new default.
For private code inside the same project, this doesn't matter, the whole project is rebuilt together. For public APIs that ship as a binary, overloading is the safer choice.
Use overloading when:
Use optional parameters when:
A pragmatic middle ground is to combine them: a small set of overloads at the top level, each delegating to a richer core method that uses optional parameters internally.
The public API is three overloads with simple signatures. The private worker has the optional parameters and the actual logic. This style gives callers a small, predictable surface while keeping the internal code DRY.
nullA literal null has no type of its own. When you pass null to an overloaded method, the compiler has to decide which reference-type parameter it should bind to. If there's only one reference-type overload, the choice is easy. If there are two or more, the call is ambiguous.
The bare null call is ambiguous because the compiler can't tell whether you meant "a null customer name" or "a null payload object." Both string and object accept null, and neither one is strictly better. Casting the literal tells the compiler exactly which overload you want.
This case shows up most often when one of the overloads has a more general type (object, IEnumerable, Func<...>) and another has a more specific type (string, a custom class). A naked null could be either, and the compiler bails.
The cast (string?)null is a compile-time annotation only. It doesn't allocate, doesn't run any conversion code, and disappears in the emitted IL. It exists purely to guide overload resolution.
The pattern works in interpolation strings too, where null arguments can quietly hit the wrong overload:
When the variable type is string?, the compiler has the type information it needs without a cast. The trouble comes specifically with the bare null literal, which has no type to read.
A subtle problem with overloading is that you control the bodies, and you can make them do anything. That freedom is dangerous. If ApplyDiscount(decimal) returns the discounted price but ApplyDiscount(decimal, decimal) mutates a global state and returns void, the caller has no way to predict the behavior from the name.
The rule of thumb: overloads should have the same meaning, different input shape. The result of calling any overload should be predictable from the method's name and the arguments.
A reasonable design:
A confusing design:
Both methods are named ApplyDiscount, but only one of them actually returns a discounted value. The other one is a side-effecting workflow that happens to involve a discount. Pick a different name for the second one, like ApplyDiscountToCart or just DiscountCart, so the caller knows what they're getting.
The same applies to return types. Mixing void, decimal, and Task<decimal> under the same overload name leaves the caller guessing about the contract. If one shape is async, name it accordingly (ApplyDiscountAsync) and don't try to merge it into the synchronous overload set.
Constructors follow the same overloading rules as regular methods. The compiler picks the right constructor based on the argument types you pass to new. A class can have multiple constructors as long as their parameter lists differ.
Each new Order(...) call goes through overload resolution to find the best constructor. The : this(...) syntax chains one constructor to another, so the convenience constructors forward to the primary one. The full mechanics, including how constructor chaining and : base(...) work, belong to the OOP section later in this course. The takeaway for now is that the overload-resolution rules in this chapter apply to constructors too.
A small, well-formed overload set: three ways to format a price.
Output (en-US current culture):
Every overload computes a formatted price. Each one takes more information than the previous. The parameter lists differ in count and types, so the compiler resolves each call with no ambiguity. The semantics are consistent: pass more arguments, get a more specific format, but the return type and meaning never change. That's the bar to aim for when you design an overload set.