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.

  1. The top two lines are inside .NET itself (the decimal division). Skip frames from System.* until you reach your own code.
  2. MeterMath.Average, line 9, is where it went wrong in your code.
  3. Program.<Main>$ is the method the compiler generates for top-level statements; line 3 called Average.

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 Exception only 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, IndexOutOfRangeException or InvalidCastException. They signal a bug; fix the code instead.