Senior 8 min · March 06, 2026

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

Master C# exception handling with try, catch, finally, and custom exceptions.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • C# exception handling uses try/catch/finally to separate error logic from business logic.
  • Catch blocks must be ordered from most specific to most general — a broad Exception catch at the top disables all specific handlers.
  • finally runs regardless of exception, return, or success — use it for resource cleanup.
  • Custom exceptions with typed properties enable type-safe handling by callers.
  • 'throw' preserves stack trace; 'throw ex' resets it — use bare 'throw' when re-throwing.
  • Exception filters (when) allow conditional catch without damaging the stack trace.
✦ Definition~90s read
What is Exception Handling in C#?

Exception handling in C# is the language's structured mechanism for responding to runtime anomalies—things like null references, network timeouts, or invalid user input—without corrupting state or crashing the process. At its core, the try/catch/finally construct lets you isolate risky code, respond to specific failure types, and guarantee cleanup (file handles, database connections, locks) regardless of whether an exception occurred.

Imagine you're driving to a friend's house and your GPS says 'turn left' — but that road is closed.

Unlike older approaches like error codes or global flags, C# exceptions propagate up the call stack automatically, forcing you to deal with failure explicitly or let it bubble to a handler. This isn't optional: unhandled exceptions in .NET terminate the application by default, so mastering these patterns is table stakes for production code.

Where this gets real is in the ecosystem. C# gives you finally blocks that execute even after a return or a re-throw—something many developers misuse by putting cleanup in catch instead. You can define custom exception classes that carry structured data (e.g., HTTP status codes, correlation IDs), and use throw to re-raise with preserved stack traces or wrap low-level exceptions in domain-specific ones.

Exception filters (when clauses) let you catch only when a condition holds—avoiding the common anti-pattern of catching everything and re-throwing. And for ASP.NET Core or background services, global handlers (IExceptionHandler, middleware, AppDomain.CurrentDomain.UnhandledException) centralize logging and prevent silent failures.

The hard truth: exceptions are for exceptional cases, not control flow. If you're catching Exception broadly or using exceptions to validate user input, you're doing it wrong. The .NET runtime is optimized for the non-exceptional path—throwing an exception costs hundreds of CPU cycles and allocates memory for the stack trace.

Real patterns include: catching specific exception types at the boundary where you can handle them (e.g., retry SqlException), using finally for deterministic cleanup (not Dispose—that's using's job), and logging the full exception details (including InnerException and Data) before letting it propagate. Tools like Serilog, Application Insights, and OpenTelemetry make this observable in production, but the pattern itself—fail fast, log rich, clean up always—is what separates robust systems from brittle ones.

Plain-English First

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.

How try/catch/finally Actually Works in C#

Exception handling in C# is a structured mechanism for propagating errors from a throw site to a matching catch block, bypassing normal control flow. The core mechanic: when code throws an exception, the runtime unwinds the call stack, executing any intervening finally blocks, until it finds a catch block whose filter matches the exception type. If none matches, the process repeats up the stack until the AppDomain's unhandled exception handler terminates the process.

Key properties that matter in practice: catch blocks are type-checked at runtime, not compile time, so order matters—place more specific exception types before general ones. Finally blocks execute unconditionally, even if the catch block rethrows or the thread is aborted via ThreadAbortException (though not for StackOverflowException or Environment.FailFast). The using statement compiles to try/finally, ensuring Dispose is called even on exception. Performance-wise, the try block itself has near-zero cost; the overhead is in the throw and stack-walk, which can be 10-100x slower than a normal return.

Use exception handling for exceptional, unpredictable failures—network timeouts, missing files, null references from external data—not for control flow. In real systems, catching Exception broadly hides bugs and makes debugging impossible. The rule: catch specific exceptions you can actually handle, let everything else crash fast so monitoring catches it.

Don't catch Exception unless you rethrow
Catching System.Exception swallows StackOverflowException, AccessViolationException, and other fatal errors that should never be caught. Always catch the most derived type you can handle.
Production Insight
A payment gateway integration caught Exception broadly and logged 'processing failed' — a StackOverflowException from a recursive retry loop was silently swallowed, causing silent payment failures for 4 hours.
Symptom: no crash, no alert, just a growing backlog of failed transactions in the dead-letter queue.
Rule: never catch Exception in a catch-all; always rethrow exceptions you cannot handle, and let global handlers log and crash.
Key Takeaway
Catch specific exceptions you can recover from; let everything else propagate.
Finally blocks are for cleanup only — never throw inside them.
Use using or try/finally to guarantee resource release, not try/catch.
C# Exception Handling Flow: try, catch, finally THECODEFORGE.IO C# Exception Handling Flow: try, catch, finally From error detection to resource cleanup and global logging try Block Monitor code for exceptions catch Block Handle specific exception types finally Block Always executes for cleanup Custom Exceptions Throw with meaningful context Global Handler Log unhandled exceptions ⚠ Catching System.Exception blindly hides errors Catch specific types; rethrow or log, never swallow THECODEFORGE.IO
thecodeforge.io
C# Exception Handling Flow: try, catch, finally
Exception Handling Csharp

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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 Optional
Always 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.
Production Insight
Catch order is critical — a general catch placed first will hide specific handlers.
The compiler may not catch reordering issues with unrelated exception types.
Always list catches from most derived to base.
Key Takeaway
Catch specific exceptions first.
A broad catch is a bug amplifier, not a safety net.
Order matters: most specific to most general.

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
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 IDisposable
For 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.
Production Insight
finally runs even with 'return' in try — use it for deterministic cleanup.
One exception: Environment.FailFast kills the process before finally runs.
Prefer 'using' for IDisposable to avoid manual try/finally.
Key Takeaway
finally guarantees cleanup.
'using' compiles to try/finally — use it for IDisposable.
Resource leaks are silent killers; finally is your shield.

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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.
Production Insight
Custom exceptions with typed properties enable callers to handle specific failures without string parsing.
'throw' preserves the original stack trace; 'throw ex' destroys it.
Always provide the serialization constructor for custom exceptions.
Key Takeaway
Custom exceptions turn magic strings into type-safe handling.
'throw' not 'throw ex' — your on-call engineer will thank you.
Design exceptions for the caller, not for the writer.

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.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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 Stack
When 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.
Production Insight
Exception filters evaluate before the catch activates — no stack unwinding if the condition is false.
Use filters for logging: 'catch when (Log(ex) == false)' never catches but always logs.
Filters are ideal for retryable versus non-retryable failures.
Key Takeaway
Exception filters conditionally catch without stack trace damage.
Use them for selective handling based on exception data.
They can log without catching — a powerful pattern.

Global Exception Handling and Logging Patterns

Even with the best per-method exception handling, some exceptions will slip through. That's where global exception handlers come in. In a console application, you can subscribe to AppDomain.CurrentDomain.UnhandledException to catch all unhandled exceptions — but note that this handler cannot prevent the process from terminating. For ASP.NET Core applications, the UseExceptionHandler middleware provides a centralized way to log exceptions and return a friendly response to the client.

Structured logging is essential here. Instead of logging a plain message, use libraries like Serilog or NLog to capture exception details, stack traces, and correlation IDs. This turns a cryptic crash into a searchable, actionable log entry that your on-call team can actually use to diagnose the issue.

The pattern that senior developers follow is to have a single global handler that logs the exception with full context and then either re-throws (for console apps) or returns a 500 error (for APIs). Never try to resume the application after an unhandled exception — the state is corrupted. Instead, let it fail fast and restart via a process manager like Docker or supervisor.

Below is an example of a global exception handler in an ASP.NET Core application using Serilog. This handler logs the exception and returns a consistent error response without exposing internal details to the client.

GlobalExceptionHandler.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Serilog;

public static class GlobalExceptionHandlerExtensions
{
    public static void UseGlobalExceptionHandler(this IApplicationBuilder app)
    {
        app.UseExceptionHandler(config =>
        {
            config.Run(async context =>
            {
                var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
                if (exceptionFeature != null)
                {
                    var exception = exceptionFeature.Error;

                    // Structured logging with Serilog — captures full exception details
                    Log.Error(exception, "Unhandled exception occurred processing request {Method} {Path}",
                        context.Request.Method, context.Request.Path);

                    context.Response.StatusCode = 500;
                    context.Response.ContentType = "application/json";

                    // Return a safe error message (no stack trace exposed to client)
                    var response = new
                    {
                        error = "An unexpected error occurred. Please try again later.",
                        correlationId = context.TraceIdentifier
                    };

                    await context.Response.WriteAsJsonAsync(response);
                }
            });
        });
    }
}

// Startup.cs usage:
// public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
// {
//     app.UseGlobalExceptionHandler();
//     // ... other middleware
// }
Why Global Handlers Need Structured Logging
A plain 'Log.Error(ex.Message)' loses the stack trace, inner exception, and correlation ID. Always use structured logging — pass the exception object directly (e.g., Log.Error(ex, "...")) so the logger captures all properties. In a production incident, the difference between 'something failed' and a full diagnostic trace is the difference between minutes and hours of investigation.
Production Insight
Global handlers catch exceptions that escape all local handlers.
Structured logging captures exception details beyond just the message.
Never try to resume after an unhandled exception — let it fail fast and restart.
Key Takeaway
Global exception handlers are your last line of defense.
Always log exceptions with full context using structured logging.
Fail fast, restart clean — don't attempt recovery from corrupted state.

Why You Should Never Catch System.Exception Without a Plan

I see this pattern in junior code all the time: a catch block swallowing Exception ex with a generic log message. That's not error handling—it's a cover-up. Catching the base Exception class is dangerous because it masks critical failures like OutOfMemoryException or StackOverflowException. These aren't recoverable; they signal a corrupted application state. The only time it's acceptable is when you rethrow immediately after logging, or you're at the top-level global handler where your sole job is to log details and shut down gracefully. In a catch block for Exception, always ask: "Can my program actually continue safely?" If the answer is unclear, log and throw. Microsoft's guidance is clear: never catch Exception unless you fully understand every exception subclass that could surface. Design your catches for specific exceptions—FileNotFoundException, InvalidOperationException—and let the rest propagate. Your operations team will thank you for not hiding bugs.

Program.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System;

class Program
{
    static void Main()
    {
        try
        {
            ProcessFile("missing.txt");
        }
        catch (FileNotFoundException ex)
        {
            Console.WriteLine($"File not found: {ex.FileName}");
        }
        catch (Exception ex) // Only at top-level; rethrow
        {
            Console.WriteLine($"Critical: {ex.Message}");
            throw; // Preserve stack trace
        }
    }

    static void ProcessFile(string path)
    {
        System.IO.File.ReadAllText(path);
    }
}
Output
File not found: missing.txt
Production Trap:
A generic catch(Exception) in a library method can silently swallow a ThreadAbortException, leaving background operations in an inconsistent state. Always catch specific types in business logic.
Key Takeaway
Catch what you can handle; let everything else crash early and loudly.

The finally Block Is Your Safety Net for Resource Cleanup

Your database connections, file streams, network sockets—these are finite resources. When an exception blows a hole in your control flow, those resources leak unless you explicitly clean up. Enter finally. It runs unconditionally, even if the try block has a return statement or the catch block rethrows. I've seen teams debate whether to use finally versus a using statement for IDisposable objects. The answer: use using when possible—it's syntactic sugar for a try/finally that calls Dispose(). But when you're managing non-disposable resources (like manually releasing an unmanaged handle), finally is your only reliable mechanism. Don't put cleanup logic in the catch block—if no exception occurs, that block never runs. And don't put it after the try/catch structure—if the exception isn't caught, execution jumps past. finally is the only guarantee.

ResourceCleanup.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System;
using System.IO;

class Program
{
    static void Main()
    {
        FileStream fs = null;
        try
        {
            fs = File.Open("data.bin", FileMode.Open);
            // Simulate a read failure
            throw new InvalidOperationException("Corrupt data");
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine($"Handled: {ex.Message}");
        }
        finally
        {
            if (fs != null)
            {
                fs.Close();
                Console.WriteLine("File handle released.");
            }
        }
    }
}
Output
Handled: Corrupt data
File handle released.
Pro Tip:
Always check for null in finally blocks when dealing with reference types. If construction fails before assignment, fs could be null—calling .Close() on null blows up and masks the original exception.
Key Takeaway
If you allocate it, clean it in finally—no exceptions to that rule.
● Production incidentPOST-MORTEMseverity: high

Silent Data Corruption from Empty Catch Block

Symptom
Customers reported orders not appearing after checkout, but no errors were logged anywhere in the application.
Assumption
The team assumed that because the catch block was present, the exception was being handled gracefully and business logic was continuing.
Root cause
A top-level catch (Exception ex) { } block was swallowing all exceptions that occurred during order processing, silently aborting the transaction.
Fix
Removed the empty catch block and replaced it with a global exception handler that logs the exception and then re-throws to allow the application to fail properly.
Key lesson
  • Never leave a catch block empty. At minimum log the exception details.
  • Use global exception handlers (AppDomain.UnhandledException) to catch and log unexpected exceptions.
  • Prefer specific exception types over broad Exception catches.
Production debug guideQuick symptom-to-action guide for production incident responders4 entries
Symptom · 01
Stack trace points to catch block line, not the original source of the error
Fix
Search for 'throw ex' — replace with bare 'throw' to preserve original stack trace.
Symptom · 02
Application continues running after exception, but data is missing or wrong
Fix
Check for empty catch blocks (catch(Exception) { }) that swallow exceptions silently.
Symptom · 03
Database connections or file handles are leaked after an exception
Fix
Verify that IDisposable resources are wrapped in using statements or try/finally blocks.
Symptom · 04
Custom exception properties are not available in the catch handler
Fix
Ensure the exception class exposes properties via public getters and is caught by its specific type, not Exception.
★ Exception Handling Quick Debug Cheat SheetUse these commands and actions to diagnose and fix common exception handling issues in C# production environments.
Stack trace shows wrong line number
Immediate action
Check if re-throwing with 'throw ex' instead of 'throw'
Commands
grep -rn 'throw ex' ~/Code/MyApp/
dotnet tool install -g dotnet-stack && dotnet-stack report
Fix now
Replace 'throw ex' with 'throw' in all catch blocks.
Exception swallowed silently, no log entry+
Immediate action
Search for empty catch blocks
Commands
grep -r 'catch\s*\(.*\)\s*{' ~/Code/MyApp/
dotnet run --configuration Debug --logger 'Console;LogLevel=Debug'
Fix now
Add logging to every catch block: _logger.LogError(ex, ...) and never leave an empty catch.
Resource leak after exception (file handle or DB connection)+
Immediate action
Check if IDisposable objects are not disposed
Commands
dotnet-counters monitor --process-id $(pidof MyApp) System.Runtime[System.IO.BytesWrites]
dotnet trace collect --providers Microsoft-Diagnostics-DiagnosticSource { using } -- MyApp.dll
Fix now
Enclose disposable objects in 'using' statements: using (var conn = new SqlConnection(...))
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

1
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.
2
'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.
3
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.
4
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

3 patterns
×

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.
×

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"); }'.
×

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 PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between 'throw' and 'throw ex' inside a catch blo...
Q02SENIOR
When should you create a custom exception class instead of using a built...
Q03SENIOR
What does the 'when' keyword do in a catch clause, and how does it diffe...
Q01 of 03SENIOR

What is the difference between 'throw' and 'throw ex' inside a catch block, and why does it matter in production debugging?

ANSWER
When you use bare 'throw' inside a catch block, it re-throws the original exception and preserves the original stack trace. This means the stack trace points to the line where the exception was first thrown, making it possible to trace the root cause. 'throw ex' resets the stack trace to the current line — the catch block — so the original throw location is lost. In a production incident, that missing information can turn a 5-minute fix into a 3-hour scavenger hunt. Always use bare 'throw' for re-throwing.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
What is the difference between catch(Exception) and a bare catch() in C#?
02
Should I catch exceptions in every method or only at the top level?
03
Does a 'return' statement inside a try block prevent the finally block from running?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's C# Basics. Mark it forged?

8 min read · try the examples if you haven't

Previous
Strings in C#
7 / 11 · C# Basics
Next
File I/O in C#