NullReferenceException C#: not the line that broke
FormatReceiptLine used a null customer. FindCustomer, the method that created that null, already returned and isn't even in the stack trace.
This order printer looks done. It loops over three orders, looks up each customer, and prints a receipt line, right up until a NullReferenceException C# stack trace on the third one:
foreach (var order in orders)
{
PrintReceipt(order);
}
void PrintReceipt(Order order)
{
var customer = FindCustomer(order.CustomerId);
var line = FormatReceiptLine(order, customer);
Console.WriteLine(line);
}
Customer? FindCustomer(int customerId) =>
customers.FirstOrDefault(c => c.Id == customerId);
string FormatReceiptLine(Order order, Customer customer) =>
$"#{order.Id} {customer.Name}: {order.Total:F2} PLN";
dotnet build already flagged it, in one line easy to scroll past:
Program.cs(22,41): warning CS8604: Possible null reference argument for parameter 'customer' in 'string FormatReceiptLine(Order order, Customer customer)'.
Nobody reads every warning, so the build ships anyway. Two receipts print fine. The third order references a customer ID that was deleted last week, and the program stops:
#100 Kowalska: 129.99 PLN
#101 Nowak: 89.50 PLN
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<<Main>$>g__FormatReceiptLine|0_2(Order order, Customer customer) in Program.cs:line 33
at Program.<<Main>$>g__PrintReceipt|0_0(Order order, <>c__DisplayClass0_0&) in Program.cs:line 22
at Program.<Main>$(String[] args) in Program.cs:line 16
The frame names look strange because PrintReceipt and FormatReceiptLine
are local functions declared inside top-level statements. The compiler
lowers each one to a hidden method on the generated Program class, named
g__<method>|<generation>_<index>, which is why the trace reads
g__FormatReceiptLine|0_2 instead of a name you actually typed. Once you
know that, the line numbers underneath still point straight at your own
source. A method on an ordinary class keeps its own name in a trace, no
prefix attached: a static Helper.Crash() throwing the same exception
shows up as plainly as at Helper.Crash() in Program.cs:line 8. Only
local functions declared inside top-level statements get the g__
treatment, because the statements themselves compile into a hidden Main
method and everything declared inside it inherits that method’s anonymity.
Line 33 is customer.Name, inside FormatReceiptLine. That’s the top
frame, and it’s tempting to read it as the location of the bug. It isn’t.
That method received customer as a parameter and trusted it. It didn’t
build the null, it just touched it first.
The frame that matters never shows up
Look for FindCustomer in that trace. It isn’t there. By the time
FormatReceiptLine throws, FindCustomer already ran, returned, and popped
off the stack, three lines earlier in PrintReceipt. A stack trace can only
show frames that are still on the stack when the exception fires, and the
frame that actually manufactured the null is long gone by then.
So the trace can’t point at the cause directly. What it can do is name the
function where the trusted value came from, if you read the calling code
around the crashing line instead of the crashing line itself.
FormatReceiptLine(order, customer) sits right below
FindCustomer(order.CustomerId), and FirstOrDefault is the part of the
framework whose entire contract is “returns null when nothing matches.”
That single call is the actual defect. Everything downstream of it just
carried the problem one hop further before it surfaced.
This is why the compiler warning matters more than the exception does. CS8604
fired on line 22, at the call into FormatReceiptLine, exactly the seam
between the value that might be null and the code that assumes it isn’t.
CS8604 is a warning, not a build error, because nullable reference types are
opt-in by convention rather than enforced from the start. The default
dotnet new console template already sets <Nullable>enable</Nullable>,
but a project written before that feature shipped would refuse to build at
all if the same annotations were errors from day one. Nothing stops a team
from raising that bar for itself: adding
<WarningsAsErrors>Nullable</WarningsAsErrors> to the same project file
turns this exact warning into error CS8604, with the identical message,
and the build refuses to finish.
Two more ways a reference goes null before anything crashes
FirstOrDefault returning null for a missing row is one source. Two others
show up just as often in code that reads clean on a first pass.
An object property that nothing ever assigns:
var cart = new ShoppingCart();
Console.WriteLine(cart.Items.Count);
public class ShoppingCart
{
public List<string> Items { get; set; }
public decimal Total { get; set; }
}
Items has no initializer and the constructor never sets it, so it starts
as null and stays null until something assigns a list to it. The compiler
already knows:
Program.cs(6,25): warning CS8618: Non-nullable property 'Items' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
Running it throws on line 2, at cart.Items.Count, a different warning code
for a different shape of the same mistake.
A configuration key that doesn’t exist:
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Smtp:Host"] = "smtp.example.com",
})
.Build();
string host = configuration["Smtp:Port"].ToUpper();
IConfiguration’s indexer returns null for a key that was never set. It
doesn’t throw KeyNotFoundException, the way a dictionary would. The build
already warns about it (CS8602: Dereference of a possibly null reference),
and running it produces the same unhandled exception as before, this time
on the .ToUpper() call.
Three different origins, three different compiler warnings, one identical NullReferenceException C# message, word for word. That’s the part a search for “object reference not set to an instance of an object” won’t answer: the text stays the same regardless of which of these produced it, so the fix has to come from reading the code around wherever a nullable value crossed into code that assumed it wasn’t.
Why guarding at the crash site isn’t the fix
The obvious patch is a null check exactly where the exception happened:
void PrintReceipt(Order order)
{
var customer = FindCustomer(order.CustomerId);
if (customer is null)
{
return;
}
var line = FormatReceiptLine(order, customer);
Console.WriteLine(line);
}
It compiles, and the program no longer crashes:
#100 Kowalska: 129.99 PLN
#101 Nowak: 89.50 PLN
Order 102 is gone from the output. No error, no log line, no exit code other than zero. A customer support agent looking at that terminal has no way to know a receipt went missing, and the person who deleted customer 3 last week has no reason to connect their change to a printer that now quietly drops one order in three. A crash is at least loud. Returning early on null trades a bug you find in minutes for one a customer reports weeks later.
The actual fix belongs where the null was born, not where it was used.
Push the decision back into FindCustomer itself:
Customer FindCustomer(int customerId) =>
customers.FirstOrDefault(c => c.Id == customerId)
?? throw new InvalidOperationException($"No customer with id {customerId}");
FindCustomer now returns a plain Customer, not Customer?, so
FormatReceiptLine never receives a value it has to guess about. Running
the same three orders throws again, but on a message that names the actual
problem instead of a generic one:
#100 Kowalska: 129.99 PLN
#101 Nowak: 89.50 PLN
Unhandled exception. System.InvalidOperationException: No customer with id 3
at Program.<<Main>$>g__FindCustomer|0_1(Int32 customerId, <>c__DisplayClass0_0&) in Program.cs:line 27
at Program.<<Main>$>g__PrintReceipt|0_0(Order order, <>c__DisplayClass0_0&) in Program.cs:line 21
at Program.<Main>$(String[] args) in Program.cs:line 16
The top frame is FindCustomer this time, right where the missing data
actually was, and the message states the customer ID instead of making you
reconstruct it from three call frames. That’s the general shape of reading
a stack trace for this kind of bug: the top frame tells you where the
program stopped, and everything below it tells you the path it took to
get there. Fixing the null well means moving the top frame closer to the
function that actually owns the missing value, not further away from it.
A shorter version of the same fix swaps FirstOrDefault for First,
which throws on its own the moment nothing matches:
Customer FindCustomer(int customerId) =>
customers.First(c => c.Id == customerId);
That also throws at the right frame, but with LINQ’s own message instead of one written for this program:
Unhandled exception. System.InvalidOperationException: Sequence contains no matching element
at System.Linq.Enumerable.First[TSource](IEnumerable`1 source, Func`2 predicate)
at Program.<<Main>$>g__FindCustomer|0_1(Int32 customerId, <>c__DisplayClass0_0&) in Program.cs:line 27
Correct frame, thin information: nothing in that text says which customer
ID was missing. The explicit throw costs one extra line and buys back
exactly the detail First leaves out.
The other two sources take the same move as FindCustomer: push the
decision to the point where the null was actually produced. required on
Items turns the silent null into a build error the moment someone
constructs a ShoppingCart without a list:
Program.cs(1,16): error CS9035: Required member 'ShoppingCart.Items' must be set in the object initializer or attribute constructor.
And configuration["Smtp:Port"] ?? throw new InvalidOperationException("Missing configuration key: Smtp:Port")
turns a missing setting into a message that says exactly which key is
absent, at startup, instead of at whatever unrelated line first tries to
read it.
Not every missing setting deserves a throw. A port number often has a
sensible default, and configuration.GetValue("Smtp:Port", 587) returns
that default cleanly instead of failing at all:
int port = configuration.GetValue("Smtp:Port", 587);
Console.WriteLine(port);
That prints 587 and exits normally. The choice between throwing and
defaulting isn’t about which one is safer in the abstract. It’s about
whether a missing value is a configuration mistake worth stopping for, or
a case the program should already know how to handle. Either answer beats
finding out three functions later that something was never set.
The Roslyn Language Server DevJourney runs underlines
CS8604, CS8618 and CS8602 while you’re still typing, before any of it
reaches a terminal. A breakpoint on FindCustomer instead of a print
statement would have shown
customer as null before the exception fired at all, and
CS0029 is what the compiler does
when it refuses outright instead of only warning.