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.
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.
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}");
}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
Steps
Enter try
Start the protected code
Run risky code
Do the work that might fail
Catch on error
Handle the matching exception
Finally cleanup
Release files and connections
Continue
Program carries on or exits cleanly
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.
| Statement | Stack trace | When to use |
|---|---|---|
throw; | Original is kept | Rethrowing inside a catch (almost always) |
throw ex; | Reset to current line | Almost never, it loses information |
throw new ...(... , ex) | New exception, old one kept as inner | Wrapping 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.
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 exception | Throw it when |
|---|---|
ArgumentNullException | A required argument was null |
ArgumentOutOfRangeException | A value is outside the allowed range |
InvalidOperationException | The object is in a state where the call makes no sense |
NotSupportedException | The operation is not supported here |
FormatException | Text 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
Steps
Exception
Something went wrong
Can I handle?
Do I know how to recover here?
Handle it
Recover and continue
Let it bubble up
If not, do not swallow it
Log at top
Log once at the app boundary
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 incatch, and clean up infinally(or useusing). - Order
catchblocks from most specific to least specific; avoid catching the baseExceptionexcept 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
whenfor 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
Exceptionand add the standard constructors. - Do not use exceptions for normal control flow, and only catch what you can truly handle.
References and further reading
- Best practices for exceptions (Microsoft Learn)
- Exception-handling statements: throw, try, catch, finally (Microsoft Learn)
- Use user-filtered exception handlers (Microsoft Learn)
- Handling and throwing exceptions in .NET (Microsoft Learn)
Related Posts
5 Awesome C# Refactoring Tips to Write Cleaner Code
Learn 5 simple C# refactoring tips with examples in modern C# 14. Make your code cleaner, safer, and easier to read using guard clauses and more.
How to Write Elegant Code with C# Pattern Matching
Learn C# pattern matching the easy way. Use is, switch expressions, property and list patterns to write clean, readable, and elegant .NET code.
How to Replace Exceptions with the Result Pattern in .NET
Learn how to replace exceptions with the Result pattern in .NET for clearer, faster, and safer error handling. Simple guide with C# examples.
Best Practices When Working With MongoDB in .NET
Learn simple, proven MongoDB best practices in .NET: singleton client, connection pooling, indexes, projections, and safe writes explained for beginners.
Top 15 Mistakes .NET Developers Make and How to Avoid Common Pitfalls
Learn the 15 most common mistakes .NET developers make with async, EF Core, HttpClient, and memory, plus simple fixes you can use today.
How to Apply Functional Programming in C#: A Beginner's Guide
Learn functional programming in C# the simple way: pure functions, immutability, records, LINQ, pattern matching, and composition with friendly examples.