C# Exception Handling Explained — try, catch, finally and Real Patterns
Every real application deals with the unexpected. Files get deleted, APIs time out, users type letters where numbers should go, and databases go offline at 2 AM. If your code has no plan for these moments, the result is a crash with a cryptic error message — or worse, silent data corruption. Exception handling is the mechanism that separates production-grade code from a weekend script that only works when nothing goes wrong.
Before exception handling existed in mainstream languages, developers had to check every single return value manually — imagine calling a function and having to verify 'did that succeed? did THAT succeed?' after every single line. It was error-prone, repetitive, and easy to miss. The try/catch model solves this by letting you separate your happy-path logic from your error-handling logic cleanly, so both are easier to read and maintain.
By the end of this article you'll understand not just the syntax of try, catch, finally, and throw — but when to use each one, how to build custom exceptions that carry real diagnostic information, and the patterns senior developers actually use in production code. You'll also learn the mistakes that cause subtle bugs even in code that 'looks' like it handles errors correctly.
The try/catch Block — Catching Errors Before They Crash Your App
The core idea is simple: you wrap the code that might fail in a 'try' block, and you describe what to do if it fails in a 'catch' block. If everything in 'try' succeeds, the 'catch' block is completely skipped. If anything throws an exception, execution jumps immediately to the matching 'catch' block — no more lines in 'try' run after the failure point.
The important word there is 'matching'. C# exceptions are organized in a class hierarchy, so you can catch specific types — like 'FileNotFoundException' — or broader ones like 'IOException', which covers a whole family of file-related errors. You should always catch the most specific exception you can handle. Catching everything with a bare 'Exception' at the top is the coding equivalent of saying 'if anything ever goes wrong, just shrug' — it hides bugs you should be fixing.
Notice in the example below how we catch two different exception types with two separate catch blocks. The order matters: C# evaluates them top to bottom and uses the first one that matches. If you put 'Exception' at the top, it will swallow everything and the specific catches below it will never fire — the compiler actually warns you about this.
using System; using System.IO; class FileReaderExample { static void Main() { // The path we're going to try reading from string configFilePath = "appsettings.json"; try { // This is the 'happy path' — what we want to happen string fileContents = File.ReadAllText(configFilePath); Console.WriteLine("Config loaded successfully:"); Console.WriteLine(fileContents); } catch (FileNotFoundException notFoundEx) { // Fires ONLY when the specific file doesn't exist // notFoundEx.FileName gives us the exact path that was missing Console.WriteLine($"Config file missing: {notFoundEx.FileName}"); Console.WriteLine("Loading default settings instead..."); } catch (UnauthorizedAccessException accessEx) { // Fires when the file exists but we don't have permission to read it Console.WriteLine($"Permission denied reading config: {accessEx.Message}"); } catch (IOException ioEx) { // Catches any other I/O problem (disk error, network drive gone, etc.) // This is broader than the two above, so it comes AFTER them Console.WriteLine($"Unexpected I/O error: {ioEx.Message}"); } // This line runs regardless of whether an exception occurred Console.WriteLine("Application startup continues..."); } }
Loading default settings instead...
Application startup continues...
The finally Block — Code That ALWAYS Runs, No Matter What
Here's a scenario: you open a database connection, then an exception fires halfway through your query. The catch block runs — great. But what about closing the database connection? If you only close it at the bottom of the try block, it never gets cleaned up when an exception occurs. You've just leaked a resource.
That's exactly what 'finally' is for. Code inside a 'finally' block runs whether the try succeeded, whether a catch ran, or even if you hit a 'return' statement inside the try. It's the guaranteed cleanup crew. Think of it as the bouncer at the door who makes sure the lights get turned off no matter how the party ends.
In modern C#, you'll often use 'using' statements instead of manual try/finally for objects that implement IDisposable — 'using' compiles down to a try/finally under the hood. But for non-disposable resources, or when you need custom cleanup logic, an explicit 'finally' block is the right tool. The example below shows a realistic database scenario where connection cleanup is non-negotiable.
using System; using System.Data.SqlClient; class DatabaseQueryExample { static void Main() { // In production this comes from config, not hardcoded string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;"; SqlConnection dbConnection = null; try { dbConnection = new SqlConnection(connectionString); dbConnection.Open(); // Could throw SqlException if server is unreachable Console.WriteLine("Connection opened. Running query..."); // Simulate a query that might fail SqlCommand command = new SqlCommand("SELECT * FROM NonExistentTable", dbConnection); SqlDataReader reader = command.ExecuteReader(); // Throws SqlException here Console.WriteLine("Query complete."); } catch (SqlException sqlEx) { // sqlEx.Number gives the SQL Server error code — useful for logging Console.WriteLine($"Database error #{sqlEx.Number}: {sqlEx.Message}"); } finally { // This block runs NO MATTER WHAT — success, failure, or return statement // Without this, a failed query would leave the connection open if (dbConnection != null && dbConnection.State == System.Data.ConnectionState.Open) { dbConnection.Close(); Console.WriteLine("Database connection closed cleanly."); } } Console.WriteLine("Method finished."); } }
Database error #208: Invalid object name 'NonExistentTable'.
Database connection closed cleanly.
Method finished.
Custom Exceptions and the 'throw' Keyword — Raising Meaningful Errors
Built-in exceptions like ArgumentNullException and InvalidOperationException are great for low-level problems, but they don't carry domain context. If your order processing system encounters an invalid coupon code, throwing a generic 'ArgumentException' with a message string isn't enough — the caller has no way to handle 'invalid coupon' differently from 'invalid email address' without parsing the message string, which is fragile and awful.
The solution is custom exceptions. You inherit from 'Exception' (or a more specific base like 'ApplicationException' — though most modern guidance says stick with Exception directly), add properties that carry domain-specific data, and throw it with a clear, structured payload. The caller can then catch your specific type and access those properties in a type-safe way.
The 'throw' keyword has two forms you need to know. 'throw new SomeException()' creates and throws a fresh exception. 'throw' on its own (inside a catch block) re-throws the current exception while preserving its original stack trace — this is critical. Using 'throw ex' instead of 'throw' resets the stack trace to the current line, destroying the diagnostic information that tells you where the problem actually started. This is one of the most common and damaging mistakes in C# exception handling.
using System; // Custom exception that carries domain-specific diagnostic info public class InvalidCouponException : Exception { // The coupon code that caused the problem — type-safe, not buried in a message string public string AttemptedCouponCode { get; } public string CustomerId { get; } public InvalidCouponException(string couponCode, string customerId) : base($"Coupon '{couponCode}' is not valid for customer '{customerId}'") { AttemptedCouponCode = couponCode; CustomerId = customerId; } // Always provide this constructor — required for proper serialization across app domains public InvalidCouponException(string message, Exception innerException) : base(message, innerException) { } } class OrderProcessor { static void ApplyDiscount(string customerId, string couponCode, decimal orderTotal) { // Simulate checking a database of valid coupons string[] validCoupons = { "SAVE10", "WELCOME20", "VIP50" }; bool couponIsValid = Array.Exists(validCoupons, c => c == couponCode); if (!couponIsValid) { // Throw our custom exception with structured data the caller can use throw new InvalidCouponException(couponCode, customerId); } Console.WriteLine($"Discount applied! New total: {orderTotal * 0.9m:C}"); } static void Main() { string customerId = "CUST-4821"; string badCoupon = "FREESTUFF"; try { ApplyDiscount(customerId, badCoupon, 150.00m); } catch (InvalidCouponException couponEx) { // We can access the structured properties — no string parsing needed Console.WriteLine($"[Order System] Coupon failed for customer {couponEx.CustomerId}"); Console.WriteLine($"[Order System] Bad code attempted: '{couponEx.AttemptedCouponCode}'"); Console.WriteLine("[Order System] Prompting user to re-enter coupon..."); // Re-throw with 'throw' (not 'throw couponEx') to preserve the original stack trace // Uncomment the line below to see the difference in a real debugging session: // throw; } catch (Exception unexpectedEx) { // Wrapping an unexpected exception preserves the original as InnerException throw new Exception("Order processing failed unexpectedly", unexpectedEx); } } }
[Order System] Bad code attempted: 'FREESTUFF'
[Order System] Prompting user to re-enter coupon...
Exception Filters and When Not to Catch — Advanced Real-World Patterns
C# 6 introduced exception filters with the 'when' keyword, and they're genuinely useful — not just a syntax novelty. They let you catch an exception only if a specific condition is true, without the exception being 'caught' in the traditional sense if the condition is false. This means the stack unwinds naturally to the next handler if your filter returns false, which is better for debugging than catching-then-rethrowing.
A classic use case: you only want to handle an HttpRequestException if the status code is a 429 (Too Many Requests), because that one is retryable. A 404 should bubble up differently. Without filters, you'd catch HttpRequestException, check the status, then either handle it or re-throw — re-throwing resets the stack trace. With a 'when' filter, the condition is evaluated before the catch activates, so no stack trace damage.
Equally important is knowing when to NOT catch exceptions. Catching an exception you can't meaningfully handle is worse than letting it propagate — you're hiding a bug. The rule of thumb senior devs use: only catch what you can recover from or what you need to translate into a better exception for the caller. Let everything else bubble up to a top-level handler (like ASP.NET's middleware) that logs it properly.
using System; using System.Net; using System.Net.Http; using System.Threading.Tasks; class HttpRetryExample { static readonly HttpClient httpClient = new HttpClient(); static async Task FetchUserDataAsync(int userId) { string apiUrl = $"https://api.example.com/users/{userId}"; int maxRetries = 3; for (int attempt = 1; attempt <= maxRetries; attempt++) { try { Console.WriteLine($"Attempt {attempt}: Calling {apiUrl}"); HttpResponseMessage response = await httpClient.GetAsync(apiUrl); // Throws HttpRequestException with StatusCode property if not successful response.EnsureSuccessStatusCode(); string userJson = await response.Content.ReadAsStringAsync(); Console.WriteLine($"Success: {userJson}"); return; // Got what we needed — exit the retry loop } catch (HttpRequestException rateLimitEx) when (rateLimitEx.StatusCode == HttpStatusCode.TooManyRequests) { // Exception filter: this catch ONLY activates for 429 responses // The 'when' condition is checked BEFORE the catch block runs // Other HttpRequestExceptions (404, 500) will NOT be caught here Console.WriteLine($"Rate limited. Waiting before retry {attempt}..."); await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt))); // Exponential backoff } catch (HttpRequestException nonRetryableEx) when (nonRetryableEx.StatusCode == HttpStatusCode.NotFound) { // 404 is NOT retryable — wrap it and throw immediately // Using 'throw new' here because we're adding context, but preserving inner throw new InvalidOperationException( $"User {userId} does not exist in the remote system.", nonRetryableEx); } // Note: HttpRequestExceptions for 500 errors are NOT caught here // They propagate up to the caller — which is correct, we can't fix server errors } Console.WriteLine("Max retries exhausted. Request failed."); } static async Task Main() { try { await FetchUserDataAsync(userId: 42); } catch (InvalidOperationException domainEx) { Console.WriteLine($"[App] Business logic error: {domainEx.Message}"); } catch (Exception unexpectedEx) { // Top-level catch — log it fully and fail gracefully Console.WriteLine($"[App] Unhandled error: {unexpectedEx.GetType().Name}: {unexpectedEx.Message}"); } } }
Rate limited. Waiting before retry 1...
Attempt 2: Calling https://api.example.com/users/42
Rate limited. Waiting before retry 2...
Attempt 3: Calling https://api.example.com/users/42
Max retries exhausted. Request failed.
| Scenario | What to Do | Why |
|---|---|---|
| You CAN recover (e.g., file missing, use default) | catch specific exception, handle it | You have a meaningful fallback — use it |
| You CANNOT recover but need to add context | catch, wrap in domain exception, re-throw | Preserve InnerException for full diagnostic chain |
| You need to re-throw unchanged | catch, then bare 'throw' | 'throw ex' destroys the original stack trace |
| Cleanup code must always run | finally block or 'using' statement | finally runs even on exception, return, or success |
| Only handle exception under specific condition | catch with 'when' filter | Filter evaluates before catch — no stack damage if false |
| You have no meaningful response to the error | Don't catch it — let it propagate | Silent swallowing hides bugs; log at the top level instead |
🎯 Key Takeaways
- Order your catch blocks from most specific to most general — C# matches the first one that fits, so a broad 'Exception' catch at the top silently disables all your specific handlers below it.
- 'throw' preserves the original stack trace; 'throw ex' resets it to the current line — in a production incident at 3 AM, this difference decides whether you find the bug in minutes or hours.
- finally blocks and 'using' statements guarantee cleanup code runs — never rely on the bottom of a try block for resource cleanup because an exception will skip it.
- Custom exceptions with typed properties (not just a message string) let callers handle specific failure modes in a type-safe way — catching a string message is fragile and breaks silently when the message wording changes.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using 'throw ex' instead of 'throw' when re-throwing — Symptom: stack traces in your logs point to your catch block, not the actual source of the error, making bugs nearly impossible to diagnose — Fix: always use bare 'throw' (no variable) inside a catch block when you want to re-throw the same exception unchanged. Only use 'throw new SomeException("context", ex)' when you're wrapping it with additional information.
- ✕Mistake 2: Swallowing exceptions with an empty catch block — Symptom: your app silently fails with no error, no log entry, and no way to know what went wrong; users see blank screens or wrong data — Fix: if you genuinely need to suppress an exception (e.g., best-effort logging that must not crash the app), at minimum log the exception details before swallowing it: 'catch (Exception ex) { _logger.LogError(ex, "Best-effort operation failed"); }'
- ✕Mistake 3: Catching 'Exception' broadly and losing specific error types — Symptom: your error handling code has a big 'catch (Exception ex)' that treats a network timeout, a null reference, and a disk-full error all the same way — Fix: define the specific exceptions your code can actually handle, catch those individually first, and let anything truly unexpected propagate to a top-level global handler (like ASP.NET Core's UseExceptionHandler middleware) that logs it with full context.
Interview Questions on This Topic
- QWhat is the difference between 'throw' and 'throw ex' inside a catch block, and why does it matter in production debugging?
- QWhen should you create a custom exception class instead of using a built-in one like ArgumentException or InvalidOperationException?
- QWhat does the 'when' keyword do in a catch clause, and how does it differ from catching the exception and checking the condition inside the catch block?
Frequently Asked Questions
What is the difference between catch(Exception) and a bare catch() in C#?
In modern C#, 'catch (Exception ex)' catches any exception that derives from the System.Exception class, which is virtually all managed exceptions. A bare 'catch {}' (with no type) can also catch non-CLS-compliant exceptions thrown from unmanaged code that don't inherit from System.Exception. In practice, bare catch blocks are extremely rare and generally discouraged — stick with 'catch (Exception ex)' for your broadest catch.
Should I catch exceptions in every method or only at the top level?
Only catch exceptions where you can do something meaningful with them — either recover, add context, or translate to a domain-specific exception. Most methods should let exceptions propagate naturally. Use a single top-level handler (ASP.NET middleware, a global UnhandledException event, or a try/catch in Main) to log anything that wasn't handled lower down. Over-catching throughout your codebase creates noise and hides real bugs.
Does a 'return' statement inside a try block prevent the finally block from running?
No — the finally block runs even when there's a 'return' statement inside the try block. The return value is captured first, the finally block executes, and then the method returns. The only situations where finally won't run are if the process is killed outright (e.g., Environment.FailFast, power loss, or a StackOverflowException that terminates the runtime).
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.