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.
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
usingSystem;
usingSystem.IO;
classFileReaderExample
{
staticvoidMain()
{
// The path we're going to try reading fromstring configFilePath = "appsettings.json";
try
{
// This is the 'happy path' — what we want to happenstring 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 missingConsole.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 itConsole.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 themConsole.WriteLine($"Unexpected I/O error: {ioEx.Message}");
}
// This line runs regardless of whether an exception occurredConsole.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
usingSystem;
usingSystem.Data.SqlClient;
classDatabaseQueryExample
{
staticvoidMain()
{
// In production this comes from config, not hardcodedstring connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;";
SqlConnection dbConnection = null;
try
{
dbConnection = newSqlConnection(connectionString);
dbConnection.Open(); // Could throw SqlException if server is unreachableConsole.WriteLine("Connection opened. Running query...");
// Simulate a query that might failSqlCommand command = newSqlCommand("SELECT * FROM NonExistentTable", dbConnection);
SqlDataReader reader = command.ExecuteReader(); // Throws SqlException hereConsole.WriteLine("Query complete.");
}
catch (SqlException sqlEx)
{
// sqlEx.Number gives the SQL Server error code — useful for loggingConsole.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 openif (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
usingSystem;
// Custom exception that carries domain-specific diagnostic infopublicclassInvalidCouponException : Exception
{
// The coupon code that caused the problem — type-safe, not buried in a message stringpublicstringAttemptedCouponCode { get; }
publicstringCustomerId { get; }
publicInvalidCouponException(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 domainspublicInvalidCouponException(string message, Exception innerException)
: base(message, innerException) { }
}
classOrderProcessor
{
staticvoidApplyDiscount(string customerId, string couponCode, decimal orderTotal)
{
// Simulate checking a database of valid couponsstring[] validCoupons = { "SAVE10", "WELCOME20", "VIP50" };
bool couponIsValid = Array.Exists(validCoupons, c => c == couponCode);
if (!couponIsValid)
{
// Throw our custom exception with structured data the caller can usethrownewInvalidCouponException(couponCode, customerId);
}
Console.WriteLine($"Discount applied! New total: {orderTotal * 0.9m:C}");
}
staticvoidMain()
{
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 neededConsole.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 InnerExceptionthrownewException("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
usingSystem;
usingSystem.Net;
usingSystem.Net.Http;
usingSystem.Threading.Tasks;
classHttpRetryExample
{
staticreadonlyHttpClient httpClient = newHttpClient();
staticasyncTaskFetchUserDataAsync(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 hereConsole.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 innerthrownewInvalidOperationException(
$"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.");
}
staticasyncTaskMain()
{
try
{
awaitFetchUserDataAsync(userId: 42);
}
catch (InvalidOperationException domainEx)
{
Console.WriteLine($"[App] Business logic error: {domainEx.Message}");
}
catch (Exception unexpectedEx)
{
// Top-level catch — log it fully and fail gracefullyConsole.WriteLine($"[App] Unhandled error: {unexpectedEx.GetType().Name}: {unexpectedEx.Message}");
}
}
}
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
usingMicrosoft.AspNetCore.Builder;
usingMicrosoft.AspNetCore.Diagnostics;
usingMicrosoft.AspNetCore.Http;
usingSerilog;
publicstaticclassGlobalExceptionHandlerExtensions
{
publicstaticvoidUseGlobalExceptionHandler(thisIApplicationBuilder 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 detailsLog.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.
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.
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.
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'
dotnet trace collect --providers Microsoft-Diagnostics-DiagnosticSource { using } -- MyApp.dll
Fix now
Enclose disposable objects in 'using' statements: using (var conn = new SqlConnection(...))
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
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.
Q02 of 03SENIOR
When should you create a custom exception class instead of using a built-in one like ArgumentException or InvalidOperationException?
ANSWER
Build a custom exception when you need to convey domain-specific data that a generic exception can't represent in a type-safe way. For example, if your order processing system fails due to an invalid coupon code, throwing a custom InvalidCouponException with properties like AttemptedCouponCode and CustomerId lets callers catch that specific type and access the data without parsing a message string. Use built-in exceptions for low-level programming errors (null arguments, invalid state). The rule of thumb: if the exception carries information that a handler needs to act on, make it custom.
Q03 of 03SENIOR
What 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?
ANSWER
The 'when' keyword creates an exception filter. The catch block only activates if the condition is true. If the condition is false, the exception is not caught — it continues propagating to the next matching handler without touching the stack trace. This contrasts with catching the exception and then checking a condition inside the catch: in that case, the exception is already caught and the stack trace is already collected. Filters are more efficient for conditions that don't need to catch the exception and also preserve the original stack trace for the next handler. They also allow logging without catching (by combining with a method that always returns false).
01
What is the difference between 'throw' and 'throw ex' inside a catch block, and why does it matter in production debugging?
SENIOR
02
When should you create a custom exception class instead of using a built-in one like ArgumentException or InvalidOperationException?
SENIOR
03
What 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?
SENIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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).