Exceptions and Error Handling
When things go wrong in C#: reading an exception, try, catch and finally, exception filters, throwing your own exceptions, using to clean up resources, and when not to use exceptions.
What an exception is
C# reports run-time problems with
exceptions: an index outside an array, text that is not a number, a file that
does not exist, a method called on null. When an
operation cannot be carried out, .NET creates an exception object
and throws it. Normal execution stops at that point, and
the runtime looks for code that handles that type of exception. If
it finds none, the program ends.
Errors that the compiler finds, such as a missing semicolon or a
string assigned to an int, are not
exceptions: the program is never built, so it never runs. This page
is about what goes wrong while the program runs.
string[] tickets = ["INC-10231", "INC-10232"];
Console.WriteLine($"First ticket: {tickets[0]}");
Console.WriteLine($"Third ticket: {tickets[2]}");
Console.WriteLine("Done"); // never reached
First ticket: INC-10231 Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.
Reading an unhandled exception
An unhandled exception prints a report to the standard error stream.
Suppose the file meter.cs computes an average over an
empty array of meter readings:
decimal[] readings = [];
Console.WriteLine("Average use:");
Console.WriteLine(MeterMath.Average(readings));
static class MeterMath
{
public static decimal Average(decimal[] values)
{
return values.Sum() / values.Length;
}
}
Average use: Unhandled exception. System.DivideByZeroException: Attempted to divide by zero. at System.Decimal.DecCalc.VarDecDiv(DecCalc& d1, DecCalc& d2) at System.Decimal.op_Division(Decimal d1, Decimal d2) at MeterMath.Average(Decimal[] values) in /home/amara/meter/meter.cs:line 9 at Program.<Main>$(String[] args) in /home/amara/meter/meter.cs:line 3
The first line names the exception type with its namespace
(System.DivideByZeroException) and its message. The
lines starting with at are the
stack trace: the chain of method calls that was active when the exception was
thrown, most recent call first — the opposite order
of a Python traceback.
-
The top two lines are inside .NET itself (the
decimaldivision). Skip frames fromSystem.*until you reach your own code. -
MeterMath.Average, line 9, is where it went wrong in your code. -
Program.<Main>$is the method the compiler generates for top-level statements; line 3 calledAverage.
As so often, the line that fails is not where the mistake is: the
division is fine, but nobody checked for an empty array. File names
and line numbers appear because the build wrote a symbol file (.pdb)
next to the program, which dotnet build does by default
in Debug and Release alike; without that file, for example in a
Native AOT executable, the trace shows only method names.
try, catch and finally
Put the code that may fail in a try block. A
catch block
names an exception type in parentheses and runs only when an
exception of that type (or a type derived from it) is thrown inside
the try. After the catch block, the
program continues below the whole statement.
string[] inputs = ["42", "12.5", "99999999999"];
foreach (string text in inputs)
{
try
{
int kwh = int.Parse(text);
Console.WriteLine($"Reading: {kwh} kWh");
}
catch (FormatException)
{
Console.WriteLine($"Not a whole number: {text}");
}
catch (OverflowException)
{
Console.WriteLine($"Too large for an int: {text}");
}
}
Console.WriteLine("Program continues");
Reading: 42 kWh Not a whole number: 12.5 Too large for an int: 99999999999 Program continues
When int.Parse throws, the rest of the
try block is skipped (no "Reading:" line), .NET checks
the catch blocks from top to bottom and runs the first
one that matches. At most one catch block runs. An
exception that no catch matches travels on to the
calling method, and so on up the call stack.
The exception object
Give the exception a name in the catch to use it. Every
exception has a Message, a StackTrace, an
optional InnerException, and its type, which
GetType().Name returns.
var tariffs = new Dictionary<string, decimal>
{
["standard"] = 0.32m,
["night"] = 0.24m,
};
try
{
Console.WriteLine(tariffs["weekend"]);
}
catch (KeyNotFoundException e)
{
Console.WriteLine(e.GetType().Name);
Console.WriteLine(e.Message);
}
KeyNotFoundException The given key 'weekend' was not present in the dictionary.
Order matters: specific first
Because a catch for a base type also catches every
derived type, a general catch above a specific one
would make the specific one unreachable. Python silently accepts
that order; the C# compiler refuses it:
try
{
Console.WriteLine(int.Parse("twelve"));
}
catch (Exception)
{
Console.WriteLine("Something went wrong");
}
catch (FormatException)
{
Console.WriteLine("Not a number");
}
example.cs(9,8): error CS0160: A previous catch clause already catches all exceptions of this or of a super type ('Exception')
finally: clean-up that always runs
A finally block runs after the try block
in every case: when it succeeds, when an exception is caught, when
an exception passes through uncaught, and when the block is left
with return. (The exception is a process that is killed
or crashes outright, for example through
Environment.FailFast.) It is the place to release
whatever the try block acquired. A
try needs at least one catch or a
finally.
Console.WriteLine(ReadGateway(online: true));
try
{
ReadGateway(online: false);
}
catch (InvalidOperationException e)
{
Console.WriteLine($"Failed: {e.Message}");
}
static string ReadGateway(bool online)
{
try
{
Console.WriteLine("Opening connection to meter gateway");
if (!online)
{
throw new InvalidOperationException("Meter gateway is offline.");
}
return "Reading received";
}
finally
{
Console.WriteLine("Closing connection");
}
}
Opening connection to meter gateway Closing connection Reading received Opening connection to meter gateway Closing connection Failed: Meter gateway is offline.
Notice the order in the first call: "Closing connection" is printed
before the caller prints the returned value, because
finally runs on the way out of the method.
The exception hierarchy in .NET
Every exception class derives from System.Exception,
forming an
exception hierarchy. Most exceptions thrown by .NET itself derive from
SystemException; the classes are spread over namespaces
such as System, System.IO and
System.Collections.Generic. Part of the tree:
Exception
└── SystemException
├── ArgumentException
│ ├── ArgumentNullException
│ └── ArgumentOutOfRangeException
├── FormatException
├── ArithmeticException
│ ├── DivideByZeroException
│ └── OverflowException
├── IndexOutOfRangeException
├── KeyNotFoundException
├── InvalidCastException
├── InvalidOperationException
│ └── ObjectDisposedException
├── NullReferenceException
├── NotSupportedException
└── IOException
├── FileNotFoundException
└── DirectoryNotFoundException
Catching a base class catches the whole branch below it, which you
can check in code with is:
Exception[] problems =
[
new ArgumentNullException("customerId"),
new FileNotFoundException("tariffs.csv not found"),
new DivideByZeroException(),
];
foreach (Exception e in problems)
{
Console.WriteLine($"{e.GetType().Name,-28} ArgumentException? {e is ArgumentException,-5} IOException? {e is IOException}");
}
ArgumentNullException ArgumentException? True IOException? False FileNotFoundException ArgumentException? False IOException? True DivideByZeroException ArgumentException? False IOException? False
The exceptions you will meet most often, and what they mean:
| Exception | Typical cause |
|---|---|
NullReferenceException |
Using a member of a variable that is null — see
Nullable Reference Types and Null Safety
|
ArgumentException and family |
A method received an argument it cannot accept |
InvalidOperationException |
The object is in the wrong state for this call, such as
First() on an empty list
|
FormatException |
Text that cannot be parsed: int.Parse("abc")
|
IndexOutOfRangeException |
An array index outside the array |
KeyNotFoundException |
A dictionary key that does not exist |
IOException and family |
Files, folders and streams — see Files, JSON and the .NET Libraries |
Exception filters with when
An
exception filter adds a
condition to a catch:
catch (SomeException e) when (condition). The block
runs only when the type matches and the condition is
true; otherwise the exception continues as if this
catch did not exist. That is useful when one exception
type covers several situations, such as an HTTP error with different
status codes.
using System.Net;
HttpStatusCode[] responses =
[
HttpStatusCode.ServiceUnavailable,
HttpStatusCode.NotFound,
];
foreach (HttpStatusCode status in responses)
{
try
{
throw new HttpRequestException("Meter gateway request failed.", null, status);
}
catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.ServiceUnavailable)
{
Console.WriteLine("503: gateway busy, retry later");
}
catch (HttpRequestException e)
{
Console.WriteLine($"{(int)e.StatusCode!}: giving up");
}
}
503: gateway busy, retry later 404: giving up
The example throws the exceptions itself, so it runs without a
network; in real code HttpClient throws them. A filter
is better than catching and then throwing again inside the block:
the filter is evaluated before the stack is unwound, so an
exception that does not match keeps its original stack trace
untouched.
Throwing exceptions
Your own methods signal errors with the
throw statement: throw new ExceptionType("message"). Throw when a
method cannot do what its name promises — and choose an existing
.NET type when one fits, because callers already know them:
-
ArgumentException,ArgumentNullException,ArgumentOutOfRangeException: the caller passed a bad argument. -
InvalidOperationException: the arguments are fine, but the object is not in a state that allows this call. -
NotSupportedException: this operation is not available at all (a read-only collection asked to add an item).
Guard clauses
Checks at the top of a method that throw on bad input are called guard clauses. The argument exceptions have static helper methods for the common checks; they fill in the parameter name for you.
Console.WriteLine(Cost(120m, 0.32m).ToString("F2"));
Console.WriteLine(Cost(-5m, 0.32m).ToString("F2"));
static decimal Cost(decimal kwh, decimal tariff)
{
ArgumentOutOfRangeException.ThrowIfNegative(kwh);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(tariff);
return kwh * tariff;
}
38.40
Unhandled exception. System.ArgumentOutOfRangeException: kwh ('-5') must be a non-negative value. (Parameter 'kwh')
ArgumentNullException.ThrowIfNull(customer) and
ArgumentException.ThrowIfNullOrWhiteSpace(name) work
the same way. Where a value is needed in an expression,
throw can also be used as an expression, typically
after ??:
var customers = new Dictionary<string, string> { ["C-1042"] = "Amara Okafor" };
Console.WriteLine(FindName("C-1042"));
try
{
Console.WriteLine(FindName("C-9999"));
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
string FindName(string id) =>
customers.GetValueOrDefault(id)
?? throw new InvalidOperationException($"No customer with id {id}.");
Amara Okafor No customer with id C-9999.
Rethrowing: throw; not throw e;
Sometimes a method wants to react to an exception — log it, roll
something back — without handling it. Inside a
catch, a bare throw; rethrows the same
exception with its stack trace intact:
Console.WriteLine(ParseReading("42"));
Console.WriteLine(ParseReading("forty"));
static int ParseReading(string text)
{
try
{
return int.Parse(text);
}
catch (FormatException)
{
Console.WriteLine($"log: could not parse '{text}'");
throw;
}
}
42 log: could not parse 'forty' Unhandled exception. System.FormatException: The input string 'forty' was not in a correct format.
Wrapping an exception
To translate a low-level exception into one that makes sense at a
higher level, throw a new exception and pass the original as the
innerException argument. Nothing is lost: the inner
exception, with its own stack trace, is printed as part of the outer
one.
string[] lines = ["INC-10231;3", "INC-10232;high"];
foreach (string line in lines)
{
try
{
Console.WriteLine($"Priority {ParsePriority(line)}");
}
catch (InvalidDataException e)
{
Console.WriteLine(e.Message);
Console.WriteLine($" caused by: {e.InnerException?.GetType().Name}");
}
}
static int ParsePriority(string line)
{
string[] parts = line.Split(';');
try
{
return int.Parse(parts[1]);
}
catch (FormatException e)
{
throw new InvalidDataException($"Bad priority in ticket {parts[0]}.", e);
}
}
Priority 3 Bad priority in ticket INC-10232. caused by: FormatException
Custom exceptions
When none of the built-in types describes your error well, and a
caller should be able to catch exactly that error, define a
custom exception: a class
that derives from Exception (or from a more specific
exception). By convention its name ends in Exception,
and it offers the three usual constructors — none, a message, and a
message with an inner exception. It can carry extra properties that
a handler may need.
var tariffs = new Dictionary<string, decimal> { ["standard"] = 0.32m };
try
{
Console.WriteLine(PriceFor("weekend", tariffs));
}
catch (TariffNotFoundException e)
{
Console.WriteLine(e.Message);
Console.WriteLine($"Code: {e.TariffCode}");
}
static decimal PriceFor(string code, Dictionary<string, decimal> tariffs) =>
tariffs.TryGetValue(code, out decimal price)
? price
: throw new TariffNotFoundException(code);
class TariffNotFoundException : Exception
{
public string? TariffCode { get; }
public TariffNotFoundException() { }
public TariffNotFoundException(string message, Exception inner) : base(message, inner) { }
public TariffNotFoundException(string tariffCode)
: base($"No tariff with code '{tariffCode}'.")
{
TariffCode = tariffCode;
}
}
No tariff with code 'weekend'. Code: weekend
Classes, constructors and : base(…) are explained in
Classes and Objects and
Inheritance, Interfaces and Polymorphism. Do not create a custom exception for every method: one per
distinct failure that callers handle differently is enough.
Cleaning up with using
Files, network connections and database connections hold resources
that the operating system gives out in limited numbers. The garbage
collector frees memory, but not at a predictable moment, so such
objects need to be closed explicitly. In .NET they implement the
IDisposable
interface, whose single method Dispose() releases the
resource.
The
using statement
calls Dispose() for you when the block ends — normally
or through an exception. It is a shorter, safer way to write
try/finally, and the C# counterpart of
Python's
with statement.
try
{
using (var connection = new MeterConnection("GW-07"))
{
connection.Read();
throw new TimeoutException("No answer from meter 3.");
}
}
catch (TimeoutException e)
{
Console.WriteLine($"Caught: {e.Message}");
}
class MeterConnection : IDisposable
{
private readonly string gateway;
public MeterConnection(string gateway)
{
this.gateway = gateway;
Console.WriteLine($"Open {gateway}");
}
public void Read() => Console.WriteLine($"Reading from {gateway}");
public void Dispose() => Console.WriteLine($"Close {gateway}");
}
Open GW-07 Reading from GW-07 Close GW-07 Caught: No answer from meter 3.
"Close" is printed before "Caught": Dispose() runs as
the exception leaves the using block, before the
catch outside it.
using declarations
Since C# 8, using var declares a variable that is
disposed at the end of the enclosing scope — the method, loop body
or block it is declared in. It saves a level of indentation. Several
of them are disposed in reverse order:
ReadAll();
Console.WriteLine("Back in the caller");
static void ReadAll()
{
using var first = new MeterConnection("GW-07");
using var second = new MeterConnection("GW-08");
first.Read();
second.Read();
Console.WriteLine("End of ReadAll");
}
class MeterConnection : IDisposable
{
private readonly string gateway;
public MeterConnection(string gateway)
{
this.gateway = gateway;
Console.WriteLine($"Open {gateway}");
}
public void Read() => Console.WriteLine($"Reading from {gateway}");
public void Dispose() => Console.WriteLine($"Close {gateway}");
}
Open GW-07 Open GW-08 Reading from GW-07 Reading from GW-08 End of ReadAll Close GW-08 Close GW-07 Back in the caller
In real code you rarely write Dispose() yourself; you
using what .NET gives you:
using var reader = new StreamReader(path);, a
FileStream, a database connection. A
using var in top-level statements lives until the end
of the program, so prefer a using block there, or put
the work in a method.
When not to use exceptions
Exceptions are for situations a method cannot handle. They are relatively slow to throw (the runtime has to capture the stack and search for a handler), and code that uses them for normal decisions is hard to follow. When failure is an ordinary, expected outcome — a user typing "twelve" into a number field, a key that may or may not be in a dictionary — test for it instead.
The TryParse pattern
.NET has a standard shape for operations that often fail: the
TryParse pattern. A method
named Try… returns a bool that says
whether it succeeded and hands the result back through an
out parameter (see
Methods and Parameters).
Nothing is thrown. int.TryParse,
decimal.TryParse, DateOnly.TryParse and
Dictionary.TryGetValue all follow it.
using System.Globalization;
for (int attempt = 1; attempt <= 2; attempt++)
{
Console.Write("Meter reading in kWh: ");
string? input = Console.ReadLine();
if (decimal.TryParse(input, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal kwh))
{
Console.WriteLine($"Saved {kwh} kWh");
}
else
{
Console.WriteLine($"'{input}' is not a number, please try again");
}
}
Meter reading in kWh: 'twelve' is not a number, please try again Meter reading in kWh: Saved 12.5 kWh
The typed text is fed to the program but not shown, so each prompt
is directly followed by the program's answer. Passing
CultureInfo.InvariantCulture makes the dot the decimal
separator whatever the computer's regional settings are; see
Strings and String Interpolation. Your own methods can follow the same pattern:
foreach (string id in new[] { "INC-10231", "REQ-2207", "INC-9" })
{
if (TryParseIncidentNumber(id, out int number))
{
Console.WriteLine($"{id}: incident {number}");
}
else
{
Console.WriteLine($"{id}: not an incident number");
}
}
static bool TryParseIncidentNumber(string id, out int number)
{
number = 0;
return id.StartsWith("INC-")
&& id.Length == 9
&& int.TryParse(id.AsSpan(4), out number);
}
INC-10231: incident 10231 REQ-2207: not an incident number INC-9: not an incident number
Good practices
- Catch what you can handle. Catch a specific type at the place where you know what to do about it: retry, ask the user again, use a default. Let everything else travel up.
-
Never swallow exceptions. An empty
catch { }(which catches everything) hides bugs and makes problems impossible to diagnose. -
Catch
Exceptiononly at the edge — the top of a program, a request handler, a background job — to log the full exception (e.ToString()includes the stack trace and inner exceptions) and fail gracefully. - Do not show stack traces to end users. They help attackers and confuse customers; log them and show a short, general message instead.
-
Do not catch
NullReferenceException,IndexOutOfRangeExceptionorInvalidCastException. They signal a bug; fix the code instead.