Inside a generic method or class, the type parameter T could be anything: a class, a struct, an int, a DateTime, or a nullable. When you need a "placeholder" value for T, you can't write null (fails for value types) or 0 (fails for reference types). The default keyword solves this. It produces the zero-initialized value for whatever T turns out to be at runtime.
default SolvesConsider a small repository for an e-commerce app, where GetById should return the customer if found, or "nothing" otherwise. With a non-generic version, picking the "nothing" value is easy: null for a class, -1 or 0 for an int, and so on. With a generic version, neither works.
The compiler error is CS0403. The reason is that T could be int, and null isn't a valid int. You can't return 0 either, because T could be Customer, and 0 isn't a valid Customer. There's no single literal that fits every possible T.
default is the answer. It evaluates to whatever the zero-initialized value is for that specific T:
If T is Customer, default(T) is null. If T is int, it's 0. If T is DateTime, it's 01/01/0001 00:00:00. The keyword adapts to whatever T ends up being at the call site.
default(T) vs the default LiteralThere are two syntactic forms. The original form, available since C# 2.0, is default(T). You pass the type in parentheses, just like typeof(T) or sizeof(int). The newer form, added in C# 7.1, is the default literal: you write just default and the compiler infers the type from context.
The literal form is the preferred style in modern C# when the target type is obvious. It removes the noise of restating the type. Use the explicit default(T) form when the target type isn't clear from context, for example as an argument inside a method call where overload resolution would otherwise be ambiguous, or inside an expression where you want the type to be unmistakable for the reader.
The last line doesn't compile. Console.WriteLine has many overloads, and the compiler can't pick one with no target type to lean on.
default(T) Produces for Each Kind of TypeThe keyword always produces the all-zeros bit pattern interpreted as T. That sounds abstract, but it lines up with what you'd expect for each category:
Kind of T | Example | default(T) |
|---|---|---|
| Reference type (class, interface, delegate) | Customer, string, IDisposable | null |
| Numeric value type | int, long, double, decimal | 0, 0L, 0.0, 0m |
bool | bool | false |
char | char | '\0' (the null character) |
DateTime | DateTime | 01/01/0001 00:00:00 |
Guid | Guid | 00000000-0000-0000-0000-000000000000 |
| Nullable value type | int?, DateTime? | null (HasValue is false) |
User-defined struct | OrderId, Money | A struct with every field set to its own default |
| Enum | OrderStatus | The member whose underlying integer value is 0 |
Array, List<T>, dictionary | int[], List<int> | null (these are reference types) |
The reference-type cases all collapse to null. The value-type cases all collapse to "every byte is zero", which then gets interpreted according to the type. The struct case is the one that surprises people most often: default(Money) doesn't call a constructor. It just zeroes out the memory. So if your struct has a decimal Amount field and a string Currency field, you get Amount = 0 and Currency = null, even though you'd never construct a Money with a null currency by hand.
This is the same behavior you get from new Money() for a struct, by the way. Structs don't allow a parameterless constructor (with one exception added in C# 10), so the "construct with all-zeros" path is the only option in both cases.
Enums are similar. The default is whatever member has the underlying value 0, even if you don't have a member with that name:
The ToString() falls back to the numeric value because no member maps to 0. This is the main argument for always defining a Pending = 0 or Unknown = 0 member: it gives default(OrderStatus) a meaningful name.
default is free. The compiler emits a single IL instruction (initobj for value types, ldnull for reference types) and there's no allocation, no constructor call, and no method dispatch. Use it freely on hot paths.
default(T)The path the compiler walks is straightforward once you've seen it. The diagram below shows what default(T) produces based on the kind of T:
The whole thing reduces to "zero out the bits and interpret them as T". The decision tree is just useful for predicting what that looks like for a particular T.
The most common place you'll meet default is as a "not found" return value, mirroring how the BCL writes its *OrDefault methods. Consider a repository that looks up customers by id:
The default on the not-found path becomes null because T is constrained to class. The return type is declared as T?, which tells callers up front that they have to handle the null case.
The same shape works for a TryGetProduct helper, except it uses an out parameter and a bool return. The out param is assigned default(T) on the miss path:
The out parameter has to be definitely assigned before the method returns. On the miss path, product = default is the standard way to satisfy that rule. It's exactly the pattern Dictionary<TKey, TValue>.TryGetValue uses internally: the out param gets set to default(TValue) whenever the key isn't found.
Another use is initializing fields and properties in a generic class. An OptionalDiscount<T> that wraps either a real discount or "no discount" can use default for the empty state:
OptionalDiscount<decimal>.None() sets Value to default, which for decimal is 0m. The HasValue = false flag is what callers actually check; the Value is only meaningful when HasValue is true. This is the same pattern Nullable<T> uses internally, just without the struct constraint.
A common pitfall. A generic FindCustomerOrDefault that checks for the not-found case like this:
What's wrong with this code?
The call-site comparison works in this specific case because T is fixed to Customer (a reference type, so == is reference equality). It breaks the moment you try to do the comparison inside the generic method, against an unconstrained T:
The compiler error is CS0019. There's no == or != operator that works for an arbitrary T. The struct constraint doesn't help (structs don't have == by default). Even the class constraint only provides reference equality, which isn't what's wanted for value-equal records.
Fix: use EqualityComparer<T>.Default, which picks the right equality strategy at runtime: IEquatable<T>.Equals if T implements it, otherwise Object.Equals.
This compiles for any T. For reference types it's effectively a null check. For value types it compares the bit pattern to all-zeros. For IEquatable<T> types like records, it calls the strongly typed Equals.
EqualityComparer<T>.Default caches a single instance per T, so repeated calls don't allocate. The first call does a small reflection-based lookup; subsequent calls go through a virtual Equals dispatch, which is on the order of a few nanoseconds. It's fine for repository-style code, but for tight inner loops on a known concrete type, write the comparison directly.
defaultOnce nullable reference types are enabled (which is the default in new .NET 8 projects), the compiler tracks whether each reference can be null. default(T) for an unconstrained or class-constrained T returns null even though the type parameter isn't declared as T?. The compiler warns about this:
Warning CS8603 says the return value could be null, but the signature promises a non-null T. There are two clean ways to fix it. The first is to change the return type to T?, which is the right answer most of the time:
Now callers see the question mark in IntelliSense and know they have to handle the null case. For a value type like int, T? becomes int? (a Nullable<int>); for a reference type like Customer, T? is a Customer that's annotated as possibly null.
The second fix is the [MaybeNull] attribute from System.Diagnostics.CodeAnalysis. It says "the return value is T, but it can be null on this path." Most code should prefer the T? style; use [MaybeNull] when changing the return type would break existing callers.
This is what IEnumerable<T>.FirstOrDefault() and IEnumerable<T>.SingleOrDefault() use internally. Their signatures are effectively [return: MaybeNull] T FirstOrDefault<T>(), which is why a call like customers.FirstOrDefault(c => c.Id == 999) returns a possibly-null Customer even though the type parameter is T, not T?.
default(T) Is Not new T()With the where T : new() constraint, new T() constructs an instance. A common mistake is to assume default(T) and new T() are the same thing. They aren't.
What's wrong with this code?
List<string> is a reference type. default(List<string>) is null, not a new empty list. The constraint says "T has a parameterless constructor", which lets you call new T(), but default(T) ignores constructors entirely. It just zero-initializes.
Fix: call new T() when you want a fresh instance.
For value-type T, default(T) and new T() happen to produce the same bytes (both zero-initialize), but the semantics differ in subtle ways. new T() runs the parameterless constructor if one exists (which is now possible for structs since C# 10). default(T) skips the constructor and produces the all-zeros value directly.
The struct's parameterless constructor sets Discount = 10m. new Coupon() runs that constructor; default(Coupon) doesn't. For arrays of Coupon, Array.Resize, and new Coupon[10], the runtime uses default, not new, so the discount comes out as 0. This is a sharp edge, even if it doesn't bite often.
defaultOnce you know what default means, it shows up everywhere in the .NET base class library. A few of the most common:
| Method | What it does when the lookup fails |
|---|---|
IEnumerable<T>.FirstOrDefault() | Returns default(T) |
IEnumerable<T>.SingleOrDefault() | Returns default(T) |
IEnumerable<T>.LastOrDefault() | Returns default(T) |
IEnumerable<T>.ElementAtOrDefault(int) | Returns default(T) |
Dictionary<TKey, TValue>.TryGetValue | Sets the out param to default(TValue) |
IList<T>.IndexOf(T) | Returns -1 (not default(int), but the same shape) |
Each of these gives you the "I didn't find anything" answer without throwing. The trade-off is that you can't distinguish "the element wasn't there" from "the element was there and happened to be default(T)" without an extra Contains or Any check. For value types like int or decimal where 0 is a real possible value, that ambiguity matters, and you should prefer the Try* flavors.
The pattern below shows default, T?, and EqualityComparer<T>.Default working together:
The GetById method returns default (i.e., null) when the dictionary doesn't have the key. The return type is T?, so callers can't dereference without acknowledging the null. The TryGetById method also uses default to assign the out parameter on the miss path. If you wanted to support value-type T, you'd drop the where T : class constraint, change T? to mean Nullable<T> for value types and "nullable annotation" for reference types, and the rest of the code would still work.
10 quizzes