Exception Handling
Definition
Exception handling is a mechanism to detect, respond to, and recover from runtime errors gracefully. C# uses a structured approach with try, catch, finally, and throw blocks built on the .NET exception hierarchy rooted at System.Exception.
Core Concepts
try / catch / finally Blocks
try
{
var result = ParseInput(input);
SaveToDatabase(result);
}
catch (FormatException ex)
{
Console.WriteLine($"Invalid format: {ex.Message}");
}
catch (DbUpdateException ex)
{
Console.WriteLine($"Database error: {ex.Message}");
}
finally
{
CloseConnection(); // Always executes
}
Catching Specific Exceptions
Catch blocks are evaluated in order. Always catch the most specific exception type first:
try
{
File.ReadAllText(path);
}
catch (FileNotFoundException ex)
{
// Most specific first
HandleMissingFile(path);
}
catch (UnauthorizedAccessException ex)
{
HandlePermissionError(ex);
}
catch (IOException ex)
{
// More general — catches other IO errors
HandleIoError(ex);
}
// Do NOT add catch (Exception) here unless you truly need it
throw and Re-throw
Use throw; to re-throw an exception while preserving the original stack trace. Using throw ex; resets the stack trace, making debugging much harder.
catch (SqlException ex)
{
_logger.LogError(ex, "Database operation failed");
throw; // Preserves stack trace
}
// Anti-pattern — NEVER do this:
catch (SqlException ex)
{
_logger.LogError(ex, "Database operation failed");
throw ex; // Destroys stack trace!
}
finally Block
The finally block always executes regardless of whether an exception was thrown or caught. Use it for cleanup:
FileStream? stream = null;
try
{
stream = File.OpenRead(path);
ProcessStream(stream);
}
finally
{
stream?.Dispose(); // Guaranteed cleanup
}
using declarationsIn modern C#, using declarations handle disposal automatically and are preferred over try/finally for resource cleanup:
using var stream = File.OpenRead(path);
ProcessStream(stream); // Dispose called automatically at scope end
Common Exception Types
| Exception Type | When It Occurs |
|---|---|
NullReferenceException | Dereferencing a null reference |
ArgumentNullException | Passing null to a non-nullable parameter |
ArgumentException | Invalid argument value |
ArgumentOutOfRangeException | Argument outside allowed range |
InvalidOperationException | Object state does not allow the operation |
FormatException | Invalid string format for parsing |
IndexOutOfRangeException | Array/indexer out of bounds |
KeyNotFoundException | Key missing from dictionary |
NotImplementedException | Method not yet implemented |
ObjectDisposedException | Operating on a disposed object |
TimeoutException | Operation exceeded time limit |
AggregateException | Combines multiple exceptions (common in TPL) |
Custom Exceptions
Create custom exceptions when you need domain-specific error types that callers can handle distinctly:
[Serializable]
public class PaymentFailedException : Exception
{
public decimal Amount { get; }
public string TransactionId { get; }
public PaymentFailedException() { }
public PaymentFailedException(string message)
: base(message) { }
public PaymentFailedException(string message, Exception innerException)
: base(message, innerException) { }
public PaymentFailedException(string message, decimal amount, string transactionId)
: base(message)
{
Amount = amount;
TransactionId = transactionId;
}
// Serialization constructor — required for proper serialization support
protected PaymentFailedException(
SerializationInfo info, StreamingContext context)
: base(info, context)
{
Amount = info.GetDecimal(nameof(Amount));
TransactionId = info.GetString(nameof(TransactionId))!;
}
}
Exception Filters (C# 6)
Use catch...when to filter exceptions before entering the catch block:
try
{
ExecuteHttpCall(request);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
await Task.Delay(TimeSpan.FromSeconds(5));
retry = true;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return Result.NotFound();
}
catch (HttpRequestException ex)
{
// All other HTTP errors
return Result.Failure(ex.Message);
}
Exception filters do not reset the stack trace, making them ideal for conditional logging:
catch (Exception ex) when (_logger.LogAndReturnFalse(ex))
{
// Never entered — LogAndReturnFalse returns false
// But the exception was logged with full stack trace
}
Inner Exceptions
Chain exceptions to preserve context while wrapping lower-level errors:
try
{
_httpClient.PostAsync(url, content).Wait();
}
catch (AggregateException ex)
{
throw new PaymentGatewayException(
"Failed to contact payment provider", ex.InnerException!);
}
Guard Clauses
Use guard clauses to fail fast at the top of a method:
public void ProcessOrder(Order? order)
{
ArgumentNullException.ThrowIfNull(order); // .NET 6+
ArgumentException.ThrowIfNullOrEmpty(order.Id); // .NET 8+
if (order.Items.Count == 0)
throw new ArgumentException("Order must contain items.", nameof(order));
if (order.Total <= 0)
throw new ArgumentOutOfRangeException(nameof(order), "Total must be positive.");
// Main logic starts here — all preconditions validated
FulfillOrder(order);
}
When to Use
- I/O operations — file, network, and database calls can always fail
- Parsing external input — user input, configuration files, API payloads
- Resource cleanup — always pair acquisition with
finallyorusing - Domain validation — throw custom exceptions for business rule violations
- Boundary layers — catch and translate exceptions at service/API boundaries
Common Pitfalls
Avoid catch (Exception) unless you are at a top-level handler (e.g., middleware). Swallowing all exceptions hides bugs and makes debugging extremely difficult. If you must catch Exception, always re-throw or log with full context.
Exceptions thrown in async void methods propagate to the synchronization context and will crash the process. Always use async Task instead of async void, except for event handlers.
If a finally block throws an exception, it replaces the original exception. Keep finally blocks simple — only dispose resources.
For converting strings to numbers, use int.TryParse, decimal.TryParse, etc. The exception-based Parse approach is significantly slower when failures are expected:
// Good — no exception on invalid input
if (int.TryParse(input, out var number))
ProcessNumber(number);
// Bad — exception for expected control flow
try { ProcessNumber(int.Parse(input)); }
catch (FormatException) { HandleInvalidInput(); }
Key Takeaways
- Always catch the most specific exception type first.
- Use
throw;(notthrow ex;) to re-throw and preserve the stack trace. - Use
finallyorusingfor guaranteed resource cleanup. - Create custom exceptions for domain-specific errors that callers need to handle distinctly.
- Exception filters (
catch...when) are powerful for conditional handling without losing stack traces. - Guard clauses at method entry points keep the main logic clean.
- Never swallow exceptions — at minimum, log them.
Interview Questions
Q: What is the difference between throw and throw ex?
throw; re-throws the caught exception while preserving the original stack trace. throw ex; resets the stack trace to the current method, hiding where the exception originally occurred. Always use throw; when re-throwing.
Q: What is the difference between finally and catch?
catch handles a specific exception type and only runs when that exception occurs. finally always runs regardless of whether an exception was thrown or caught. Use catch for error handling and finally for cleanup.
Q: When should you create custom exceptions?
Create custom exceptions when callers need to handle a specific domain error differently from general errors. Name them descriptively (e.g., InsufficientFundsException), inherit from Exception, and include a serialization constructor.
Q: What is an exception filter?
An exception filter (catch...when) is a C# 6 feature that evaluates a condition before entering a catch block. If the condition is false, the catch block is skipped and the exception continues propagating. Filters preserve the stack trace and avoid re-throwing.
Q: Why should you avoid async void?
Exceptions thrown in async void methods cannot be caught by the caller and will crash the application via the synchronization context. Use async Task so the caller can await and handle exceptions.