Home C# / .NET C# Exception Handling Explained — try, catch, finally and Real Patterns

C# Exception Handling Explained — try, catch, finally and Real Patterns

In Plain English 🔥
Imagine you're driving to a friend's house and your GPS says 'turn left' — but that road is closed. The GPS doesn't just freeze or explode; it says 'recalculating' and finds you a new route. Exception handling is your program's 'recalculating' system. When something unexpected happens — a file that doesn't exist, a network that drops, a number divided by zero — instead of your app crashing and burning, you catch the problem, handle it gracefully, and keep going (or at least tell the user something useful).
⚡ Quick Answer
Imagine you're driving to a friend's house and your GPS says 'turn left' — but that road is closed. The GPS doesn't just freeze or explode; it says 'recalculating' and finds you a new route. Exception handling is your program's 'recalculating' system. When something unexpected happens — a file that doesn't exist, a network that drops, a number divided by zero — instead of your app crashing and burning, you catch the problem, handle it gracefully, and keep going (or at least tell the user something useful).

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.

FileReaderExample.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940
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...");
    }
}
▶ Output
Config file missing: appsettings.json
Loading default settings instead...
Application startup continues...
⚠️
Watch Out: Catch Order Is Not OptionalAlways order catch blocks from most specific to most general. If you put 'catch (Exception ex)' first, it catches everything and the specific handlers below it become dead code. The compiler warns you, but only when the types have a direct inheritance relationship — it won't always save you.

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.

DatabaseQueryExample.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243
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.");
    }
}
▶ Output
Connection opened. Running query...
Database error #208: Invalid object name 'NonExistentTable'.
Database connection closed cleanly.
Method finished.
⚠️
Pro Tip: Prefer 'using' for IDisposableFor anything that implements IDisposable (SqlConnection, StreamReader, HttpClient), use a 'using' statement instead of manual try/finally. It's shorter, it's idiomatic C#, and it compiles to the same IL. Reserve explicit finally blocks for cleanup logic that doesn't fit the dispose pattern.

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.

OrderProcessingExample.cs · CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
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);
        }
    }
}
▶ Output
[Order System] Coupon failed for customer CUST-4821
[Order System] Bad code attempted: 'FREESTUFF'
[Order System] Prompting user to re-enter coupon...
🔥
Interview Gold: throw vs throw ex'throw' inside a catch block re-throws the original exception and keeps its stack trace intact — essential for debugging. 'throw ex' creates a new stack trace starting at that line, making it look like the exception originated in your catch block. Always use bare 'throw' when re-throwing. This comes up in almost every senior C# interview.

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.

HttpRetryExample.cs · CSHARP
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
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}");
        }
    }
}
▶ Output
Attempt 1: Calling https://api.example.com/users/42
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.
⚠️
Pro Tip: Exception Filters Don't Unwind the StackWhen a 'when' condition evaluates to false, the exception is NOT caught — it continues searching for the next matching handler without touching the stack trace. This makes filters ideal for logging without catching: 'catch (Exception ex) when (LogException(ex) == false)' logs every exception while still letting them propagate. LogException always returns false, so the catch never actually fires.
ScenarioWhat to DoWhy
You CAN recover (e.g., file missing, use default)catch specific exception, handle itYou have a meaningful fallback — use it
You CANNOT recover but need to add contextcatch, wrap in domain exception, re-throwPreserve InnerException for full diagnostic chain
You need to re-throw unchangedcatch, then bare 'throw''throw ex' destroys the original stack trace
Cleanup code must always runfinally block or 'using' statementfinally runs even on exception, return, or success
Only handle exception under specific conditioncatch with 'when' filterFilter evaluates before catch — no stack damage if false
You have no meaningful response to the errorDon't catch it — let it propagateSilent 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).

🔥
TheCodeForge Editorial Team Verified Author

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.

← PreviousStrings in C#Next →File I/O in C#
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged