← All posts

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.

Artur Kot 9 min read

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.

Topics: C#Errors and messagesDebuggingFundamentals

The download has started

DevJourney_2.1.1_x64-setup.exe · 2.1.1

If your system warns you, and how to check the file

If Windows warns you

"Windows protected your PC" is not a virus detection. SmartScreen trusts the certificate an installer is signed with, and this one is still earning its reputation through a download count.

In the warning: More info → Run anyway.

Confirm it yourself

Check this download on VirusTotal ↗

Or in PowerShell, where you downloaded it:

Get-FileHash .\DevJourney_2.1.1_x64-setup.exe

Should print:

2e441de05e02ae32caad96bdca86f9ca0ff59e01cbc8b289a1f6f783e4f7bc46