Skip to main content
SEMastery
.NET Corebeginner

Mastering Exception Handling in C#: A Comprehensive Guide

Learn C# exception handling the friendly way: try, catch, finally, custom exceptions, filters, throw vs throw ex, and real best practices for .NET 10.

11 min readUpdated March 28, 2026

Imagine you are riding a train across India. The train is running fine, station after station, exactly on schedule. Then suddenly the driver spots a cow sitting on the track. The driver cannot pretend the cow is not there. The driver must stop, deal with the problem, and then decide what happens next. Maybe the cow moves and the train continues. Maybe the journey ends early. Either way, the train does not crash into the cow and pretend everything is normal.

An exception in C# is that cow on the track. Your program is running happily, then something unexpected blocks the way: a file is missing, the internet drops, a number is divided by zero. C# stops, raises its hand, and says "I cannot continue like this." Exception handling is how you, the driver, decide what to do next. You can clear the track and carry on, or you can stop the journey safely. What you should never do is ignore the cow.

In this guide you will learn how exceptions work, how to catch them, how to clean up afterwards, how to make your own exceptions, and the habits that separate beginner code from professional code. The examples use C# 14, which ships with .NET 10 (the current LTS release), but everything here works in older versions too.

What an exception actually is

An exception is an object. When something goes wrong, C# creates an instance of a class that derives from System.Exception. That object carries useful information: a message, the type of error, and a stack trace that shows the exact path your code took to reach the trouble.

When an exception is created, normal execution stops at once. C# then starts walking back up your call stack, looking for code that knows how to handle this kind of problem. This walk is called unwinding the stack. If nobody handles it, the program crashes and the exception details are printed out.

How an unhandled exception travels up the call stack until the program crashes

The key idea: an exception keeps travelling upward until someone catches it. Your job is to put a catch in the right place, not everywhere.

The try and catch blocks

The most basic tool is the try and catch pair. You wrap risky code in a try block. If an exception happens inside it, control jumps straight to the matching catch block.

try
{
    int[] numbers = { 10, 20, 30 };
    Console.WriteLine(numbers[5]); // there is no index 5
}
catch (IndexOutOfRangeException ex)
{
    Console.WriteLine($"Oops, that slot does not exist: {ex.Message}");
}

Here the array only has three items, so asking for index 5 throws an IndexOutOfRangeException. Without the try and catch, the program would crash. With it, we print a friendly message and keep going.

Notice that we caught a specific exception type, not the general one. This matters a lot, and we will come back to it.

Catch order: most specific first

You can have many catch blocks for one try. C# checks them from top to bottom and uses the first one that fits. Because of this, you must always order them from the most specific type to the least specific type. If you put the base Exception first, it greedily catches everything, and the more specific blocks below it can never run. The compiler will even warn you.

try
{
    var data = File.ReadAllText("settings.txt");
    var value = int.Parse(data);
}
catch (FileNotFoundException ex)
{
    Console.WriteLine("The settings file is missing.");
}
catch (FormatException ex)
{
    Console.WriteLine("The file did not contain a valid number.");
}
catch (Exception ex)
{
    // last resort, for anything we did not expect
    Console.WriteLine($"Something else went wrong: {ex.Message}");
}
C# checks each catch block in order and runs the first one whose type matches

The finally block: cleanup that always runs

Sometimes you open something that must be closed no matter what happens: a file, a database connection, a network socket. The finally block is made for this. Code inside finally runs whether the try succeeded, threw an exception, or even returned early.

Think of it like switching off the lights when you leave a room. Whether you had a good day or a bad day inside, the lights still need to go off on the way out.

StreamReader? reader = null;
try
{
    reader = new StreamReader("report.txt");
    Console.WriteLine(reader.ReadToEnd());
}
catch (FileNotFoundException)
{
    Console.WriteLine("Report not found.");
}
finally
{
    // runs even if the file was missing
    reader?.Dispose();
    Console.WriteLine("Cleanup done.");
}

In real code you would prefer a using statement, which calls Dispose for you automatically. But finally is the engine underneath, and it is good to understand it.

The lifecycle of a try / catch / finally block

Enter try
Run risky code
Catch on error
Finally cleanup
Continue

Steps

1

Enter try

Start the protected code

2

Run risky code

Do the work that might fail

3

Catch on error

Handle the matching exception

4

Finally cleanup

Release files and connections

5

Continue

Program carries on or exits cleanly

Finally always runs, whether the code succeeds or throws

throw vs throw ex: keep the stack trace

This is one of the most common mistakes beginners make, so read this part slowly.

When you catch an exception and want to pass it further up (rethrow it), you have two choices. They look almost the same but behave very differently.

  • throw; rethrows the same exception and keeps the original stack trace. You still know exactly where the trouble began.
  • throw ex; throws the exception again but resets the stack trace to this line. The real source is now hidden.
StatementStack traceWhen to use
throw;Original is keptRethrowing inside a catch (almost always)
throw ex;Reset to current lineAlmost never, it loses information
throw new ...(... , ex)New exception, old one kept as innerWrapping a low-level error in a friendly one
try
{
    ProcessPayment();
}
catch (Exception ex)
{
    Log(ex);
    throw; // good: keeps the original trail
    // throw ex; // bad: hides where it really started
}

If you need to wrap the error in something more meaningful, create a new exception and pass the old one as the inner exception. That way you get a clear message on top and the full history underneath.

catch (SqlException ex)
{
    throw new OrderSaveException("Could not save the order.", ex);
}

Exception filters with the when keyword

C# lets you add a condition to a catch block using the when keyword. The catch only runs if the type matches and the condition is true. This is called an exception filter.

Filters are handy because they keep the stack untouched while deciding whether to handle the error. They are also cleaner than catching, checking with an if, and rethrowing.

try
{
    await CallApiAsync();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    Console.WriteLine("That resource does not exist.");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    Console.WriteLine("Slow down, we are being rate limited.");
}

One important rule from the official docs: the condition inside when should be simple and must not cause side effects. Keep it to a quick boolean check. A filter that changes state or calls slow code can cause surprising bugs.

An exception filter only catches when the type matches and the when condition is true

Creating your own custom exceptions

Built-in exceptions cover most situations, but sometimes your app has its own kind of problem. Maybe an order cannot be shipped, or a coupon code has expired. When callers need to react to that exact situation, a custom exception makes your intent clear.

The rules are simple. Derive from Exception, end the name with Exception, and add the three standard constructors so it behaves like every other exception in .NET.

public class CouponExpiredException : Exception
{
    public string CouponCode { get; }
 
    public CouponExpiredException() { }
 
    public CouponExpiredException(string message)
        : base(message) { }
 
    public CouponExpiredException(string message, Exception inner)
        : base(message, inner) { }
 
    public CouponExpiredException(string couponCode, string message)
        : base(message) => CouponCode = couponCode;
}

Now callers can write catch (CouponExpiredException ex) and handle just that case, perhaps by showing the user a "this coupon is no longer valid" message while letting other errors travel on.

Built-in exceptionThrow it when
ArgumentNullExceptionA required argument was null
ArgumentOutOfRangeExceptionA value is outside the allowed range
InvalidOperationExceptionThe object is in a state where the call makes no sense
NotSupportedExceptionThe operation is not supported here
FormatExceptionText could not be parsed into the expected shape

Prefer these built-in types when they fit. Only invent a custom exception when none of them express your meaning well.

Throwing exceptions the right way

Throwing is just as important as catching. A good throw tells the caller exactly what went wrong and gives them a chance to fix it.

Modern .NET adds helper methods that make argument checks short and readable. They throw the correct exception for you with a clear message.

public void Withdraw(decimal amount)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
 
    if (amount > Balance)
        throw new InvalidOperationException("Not enough balance.");
 
    Balance -= amount;
}

These guard helpers (ArgumentNullException.ThrowIfNull, ArgumentOutOfRangeException.ThrowIfNegativeOrZero, and friends) reduce noise and keep the happy path easy to read.

Deciding whether to catch an exception

Exception
Can I handle?
Handle it
Let it bubble up
Log at top

Steps

1

Exception

Something went wrong

2

Can I handle?

Do I know how to recover here?

3

Handle it

Recover and continue

4

Let it bubble up

If not, do not swallow it

5

Log at top

Log once at the app boundary

A simple rule: only catch what you can actually handle

Best practices that matter

Here is the wisdom from the Microsoft Learn guidance, in plain words.

Only catch what you can handle. Do not catch an exception unless you can leave the program in a known, safe state. Catching an error just to hide it is worse than letting it crash, because now the bug is silent.

Do not use exceptions for normal flow. Exceptions are for the unexpected. If you can check a condition with an if first (like int.TryParse instead of catching a FormatException), do that. Exceptions are relatively slow, so they belong on the rare path, not the common one.

Catch specific types. Order them from most derived to least derived. Reserve the base Exception for a single top-level handler that logs and shuts down gracefully.

Preserve the stack trace. Use plain throw; when rethrowing. Wrap with an inner exception when you need a friendlier message.

Clean up with finally or using. Files, connections, and locks must always be released, success or failure.

Write helpful messages. A message like "Order 4521 failed because the warehouse is closed" helps far more than "Error occurred".

A note on async code

Exceptions in async methods work the same way, with one twist. When you await a task, any exception inside it is rethrown at the await point, so you can wrap the await in a normal try and catch. But if you forget to await (fire and forget), the exception can be lost. Always await your tasks, or handle their faults explicitly.

try
{
    await SendEmailAsync(order);
}
catch (SmtpException ex)
{
    Log(ex);
    // decide: retry, queue for later, or tell the user
}

Quick recap

  • An exception is C# telling you something went wrong; it stops execution and travels up the stack looking for a handler.
  • Wrap risky code in try, handle it in catch, and clean up in finally (or use using).
  • Order catch blocks from most specific to least specific; avoid catching the base Exception except at the top of your app.
  • Use plain throw; to rethrow and keep the original stack trace. Wrap with an inner exception for a friendlier message.
  • Use exception filters with when for clean, condition-based handling, and keep the condition side-effect free.
  • Create a custom exception only when callers need to react to that exact case; derive from Exception and add the standard constructors.
  • Do not use exceptions for normal control flow, and only catch what you can truly handle.

References and further reading

Related Posts