Overview
Exceptions in C# are the standard mechanism for reporting and handling runtime error conditions that prevent code from completing its intended operation. An exception represents an abnormal condition, such as invalid input, a missing file, a failed network call, an unavailable database, unauthorized access, or an invalid object state.
In C#, exception handling is built around the try, catch, finally, and throw keywords. Code that might fail is placed inside a try block, recoverable failures are handled in one or more catch blocks, cleanup logic can be placed in a finally block, and new exceptions can be raised with throw.
Exceptions matter because they affect application reliability, debugging, API design, logging, security, and user experience. In production .NET applications, exceptions are commonly handled at application boundaries, such as ASP.NET Core middleware, background worker boundaries, message consumer boundaries, scheduled jobs, and command handlers.
For interviews, exceptions are important because they test whether a developer understands more than just syntax. Interviewers often expect candidates to know when to throw exceptions, when not to throw exceptions, how to preserve stack traces, how finally relates to resource cleanup, how exceptions work with async/await, how to design custom exceptions, and how to build clean error handling in Web APIs.
Core Concepts
What Is an Exception?
An exception is an object that represents an error or unexpected condition during program execution. In .NET, exception types derive from System.Exception.
Common built-in exception types include:
ArgumentExceptionArgumentNullExceptionArgumentOutOfRangeExceptionInvalidOperationExceptionNotSupportedExceptionUnauthorizedAccessExceptionFileNotFoundExceptionIOExceptionTimeoutExceptionOperationCanceledException
Example:
public decimal CalculateDiscount(decimal price, decimal discountPercent)
{
if (price < 0)
{
throw new ArgumentOutOfRangeException(nameof(price), "Price cannot be negative.");
}
if (discountPercent is < 0 or > 100)
{
throw new ArgumentOutOfRangeException(nameof(discountPercent), "Discount must be between 0 and 100.");
}
return price * (discountPercent / 100);
}
In this example, the method throws exceptions because it cannot correctly complete its defined responsibility when the input is invalid.
Exception Handling Syntax
The main exception handling keywords are:
Example:
try
{
string text = File.ReadAllText("settings.json");
Console.WriteLine(text);
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"Configuration file was not found: {ex.FileName}");
}
catch (UnauthorizedAccessException)
{
Console.WriteLine("The application does not have permission to read the file.");
}
finally
{
Console.WriteLine("File read attempt finished.");
}
A try block must have at least one catch, one finally, or both.
How Exception Propagation Works
When an exception is thrown, the runtime searches the call stack for the nearest matching catch block. If no suitable handler is found, the exception continues to propagate up the stack. If it remains unhandled, the application may terminate or the host may convert it into an error response.
Example:
public void ProcessOrder(int orderId)
{
ValidateOrder(orderId);
SaveOrder(orderId);
}
private void ValidateOrder(int orderId)
{
if (orderId <= 0)
{
throw new ArgumentOutOfRangeException(nameof(orderId));
}
}
If ValidateOrder throws and ProcessOrder does not catch it, the exception moves to the caller of ProcessOrder.
In real applications, exceptions are often allowed to bubble up to a boundary where they can be logged and translated into a user-friendly response.
Choosing the Right Exception Type
Choosing a specific exception type makes code easier to understand, test, and handle.
Common choices:
Example:
public void SendEmail(string recipient, string subject)
{
ArgumentException.ThrowIfNullOrWhiteSpace(recipient);
ArgumentException.ThrowIfNullOrWhiteSpace(subject);
if (!_smtpClient.IsConnected)
{
throw new InvalidOperationException("Email cannot be sent because the SMTP client is not connected.");
}
// Send email...
}
Best practice is to throw the most specific exception that accurately describes the problem.
Throwing Exceptions
Use throw new when creating a new exception:
throw new InvalidOperationException("The order has already been submitted.");
Use guard clauses near the start of a method for invalid arguments:
public Customer GetCustomer(Guid customerId)
{
if (customerId == Guid.Empty)
{
throw new ArgumentException("Customer ID cannot be empty.", nameof(customerId));
}
// Query customer...
}
Modern C# also provides helper methods for common argument validation:
public void CreateUser(string username)
{
ArgumentException.ThrowIfNullOrWhiteSpace(username);
// Create user...
}
Avoid intentionally throwing overly broad or runtime-reserved exceptions such as:
throw new Exception("Something went wrong"); // Too generic
throw new NullReferenceException(); // Usually indicates a programming bug
throw new IndexOutOfRangeException(); // Usually thrown by the runtime
Catching Exceptions
Catch exceptions when you can do something meaningful with them, such as:
- Retry the operation
- Use a fallback
- Convert the error to a domain-specific result
- Return an appropriate HTTP response
- Log the exception at an application boundary
- Add context and rethrow
- Release or roll back resources
Example:
public async Task<string?> TryLoadConfigurationAsync(string path)
{
try
{
return await File.ReadAllTextAsync(path);
}
catch (FileNotFoundException)
{
return null;
}
}
This catch block is meaningful because a missing optional configuration file is expected and recoverable.
Avoid this pattern:
try
{
ProcessPayment();
}
catch
{
// Bad: silently hides the real problem
}
Swallowing exceptions makes production issues hard to diagnose.
Catching Specific Exceptions Before General Exceptions
When using multiple catch blocks, place more specific exception types before more general exception types.
Correct:
try
{
LoadFile();
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"File missing: {ex.FileName}");
}
catch (IOException ex)
{
Console.WriteLine($"File I/O error: {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"Unexpected error: {ex.Message}");
throw;
}
Incorrect:
try
{
LoadFile();
}
catch (Exception)
{
// This catches everything derived from Exception first.
}
catch (FileNotFoundException)
{
// This is unreachable.
}
The compiler prevents unreachable catch blocks when a more general exception type appears before a more specific one.
throw; vs throw ex;
One of the most common C# interview questions is the difference between throw; and throw ex;.
Use throw; to rethrow the current exception while preserving the original stack trace:
try
{
ProcessOrder();
}
catch (Exception ex)
{
_logger.LogError(ex, "Order processing failed.");
throw;
}
Avoid throw ex; because it resets the stack trace to the current catch block location:
try
{
ProcessOrder();
}
catch (Exception ex)
{
_logger.LogError(ex, "Order processing failed.");
throw ex; // Bad: loses original stack trace information
}
Preserving stack traces is important for debugging production issues.
Exception Filters with when
Exception filters let you catch an exception only when a condition is true.
Example:
try
{
await CallExternalApiAsync();
}
catch (HttpRequestException ex) when (ex.Message.Contains("429"))
{
Console.WriteLine("Rate limit was reached.");
}
Another example:
try
{
ProcessPayment(payment);
}
catch (PaymentException ex) when (ex.IsRetryable)
{
await RetryPaymentAsync(payment);
}
Exception filters are useful when the exception type alone is not enough to decide how to handle the problem.
finally and Resource Cleanup
A finally block runs after a try block finishes, regardless of whether an exception was thrown or caught. It is commonly used for cleanup.
Example:
FileStream? stream = null;
try
{
stream = File.OpenRead("report.csv");
// Read file...
}
finally
{
stream?.Dispose();
}
In modern C#, prefer using statements or using declarations for disposable resources:
using var stream = File.OpenRead("report.csv");
// Use stream...
A using statement is compiled into a try/finally pattern that ensures Dispose is called.
For asynchronous cleanup, use await using with IAsyncDisposable:
await using var connection = await OpenConnectionAsync();
// Use connection...
Exceptions and using
using helps ensure resources are released even when exceptions occur.
Example:
public string ReadReport(string path)
{
using var reader = new StreamReader(path);
return reader.ReadToEnd();
}
This is safer than manually opening and closing the resource because Dispose is called even if ReadToEnd throws.
Common mistake:
var reader = new StreamReader(path);
string text = reader.ReadToEnd();
reader.Dispose();
If ReadToEnd throws, Dispose may never run. Use using instead.
Custom Exceptions
A custom exception is useful when a built-in exception type is not expressive enough and callers need to handle a specific business or application error.
Example:
public sealed class PaymentDeclinedException : Exception
{
public string TransactionId { get; }
public PaymentDeclinedException()
{
}
public PaymentDeclinedException(string message)
: base(message)
{
}
public PaymentDeclinedException(string message, Exception innerException)
: base(message, innerException)
{
}
public PaymentDeclinedException(string message, string transactionId)
: base(message)
{
TransactionId = transactionId;
}
}
Use custom exceptions when:
- The exception represents a meaningful domain or application failure
- Callers may catch that specific exception type
- Additional structured properties are useful
- A built-in exception does not describe the failure clearly
Avoid creating custom exceptions for every small validation case. Many cases are better handled with built-in exceptions or validation results.
InnerException
InnerException preserves the original exception when wrapping it with a higher-level exception.
Example:
try
{
await _paymentGateway.ChargeAsync(request);
}
catch (HttpRequestException ex)
{
throw new PaymentGatewayException("Failed to call payment gateway.", ex);
}
This gives the caller a more meaningful application-level exception while preserving the low-level cause.
Without InnerException, important debugging details may be lost.
Exceptions in async and await
In async methods, exceptions are captured in the returned Task. They are rethrown when the task is awaited.
Example:
public async Task<string> LoadDataAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Data source failed.");
}
try
{
string data = await LoadDataAsync();
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
Important points:
- Exceptions in
async Taskmethods are observed when awaited. - Exceptions in
async voidmethods are difficult to catch and should generally be avoided except for event handlers. - Argument validation can be thrown synchronously before asynchronous work begins.
- Blocking on tasks with
.Wait()or.Resultcan wrap exceptions inAggregateExceptionand may cause deadlocks in some application types.
Prefer:
await DoWorkAsync();
Avoid:
DoWorkAsync().Wait();
var result = DoWorkAsync().Result;
Exceptions with Task.WhenAll
When awaiting Task.WhenAll, multiple tasks may fail. The awaited exception often exposes one exception directly, while the returned task can contain multiple exceptions.
Example:
Task task1 = SaveCustomerAsync(customer);
Task task2 = SendEmailAsync(customer.Email);
try
{
await Task.WhenAll(task1, task2);
}
catch
{
if (task1.Exception is not null)
{
foreach (Exception ex in task1.Exception.InnerExceptions)
{
Console.WriteLine($"Save failed: {ex.Message}");
}
}
if (task2.Exception is not null)
{
foreach (Exception ex in task2.Exception.InnerExceptions)
{
Console.WriteLine($"Email failed: {ex.Message}");
}
}
throw;
}
In interviews, a strong answer should mention that parallel asynchronous operations can produce more than one failure.
Exceptions and Cancellation
Cancellation is not always an error. In .NET, cancellation is commonly represented by OperationCanceledException or TaskCanceledException.
Example:
public async Task ProcessAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(1000, cancellationToken);
}
When handling exceptions, avoid treating cancellation as a normal failure.
Example:
try
{
await ProcessAsync(cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("Operation was canceled by request.");
}
In ASP.NET Core, request cancellation can happen when the client disconnects. Logging cancellation as an error can create noisy logs.
Exceptions vs Return Values vs Result Pattern
Exceptions are best for unexpected or exceptional failures where the method cannot complete its intended work. Return values or result objects are often better for expected business outcomes.
Exception example:
public Customer GetCustomer(Guid id)
{
return _repository.Find(id)
?? throw new CustomerNotFoundException($"Customer '{id}' was not found.");
}
Result pattern example:
public sealed record Result<T>(bool IsSuccess, T? Value, string? Error);
public Result<Customer> TryGetCustomer(Guid id)
{
Customer? customer = _repository.Find(id);
if (customer is null)
{
return new Result<Customer>(false, null, "Customer was not found.");
}
return new Result<Customer>(true, customer, null);
}
General guidance:
A common interview mistake is saying "always use exceptions" or "never use exceptions." Good design depends on whether the failure is exceptional, expected, recoverable, or part of normal business flow.
Exceptions and Performance
Throwing exceptions is relatively expensive compared with normal control flow because the runtime must create exception objects and capture stack information. However, the main concern is not usually raw speed; the bigger issue is design clarity.
Bad:
public bool IsNumber(string input)
{
try
{
int.Parse(input);
return true;
}
catch
{
return false;
}
}
Better:
public bool IsNumber(string input)
{
return int.TryParse(input, out _);
}
Use exceptions for exceptional conditions, not for frequent expected decisions.
Global Exception Handling in ASP.NET Core
In Web APIs, exceptions should not usually be caught in every controller action. A better approach is to use centralized exception handling.
Example:
app.UseExceptionHandler();
builder.Services.AddProblemDetails();
A typical production API maps exceptions to appropriate HTTP responses:
Example custom middleware concept:
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception occurred.");
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new
{
title = "An unexpected error occurred.",
status = 500
});
}
}
}
In production, do not expose stack traces or sensitive exception details to clients.
Logging Exceptions
Good exception logging should include enough context to diagnose the issue without leaking sensitive data.
Good:
_logger.LogError(
ex,
"Failed to process order {OrderId} for customer {CustomerId}.",
orderId,
customerId);
Bad:
_logger.LogError(ex.Message);
Logging only ex.Message loses stack trace and structured diagnostic information.
Avoid logging the same exception repeatedly at every layer. A common approach is:
- Add useful context if needed
- Rethrow with
throw; - Log once at the application boundary
Common Exception Handling Mistakes
Common mistakes include:
- Catching
Exceptioneverywhere - Swallowing exceptions silently
- Using
throw ex;instead ofthrow; - Throwing generic
Exception - Using exceptions for normal control flow
- Returning exception objects instead of throwing them
- Logging sensitive data in exception messages
- Catching an exception only to rethrow it without adding value
- Forgetting to preserve
InnerException - Blocking async code with
.Resultor.Wait() - Treating cancellation as a production error
- Exposing stack traces in API responses
- Creating too many custom exception types without a real handling need
Best Practices
Use these practical best practices:
- Throw exceptions only when a method cannot complete its intended responsibility.
- Prefer specific exception types.
- Validate arguments early.
- Use
ArgumentNullException.ThrowIfNulland related guard helpers when appropriate. - Catch only exceptions you can handle meaningfully.
- Use
throw;to rethrow and preserve stack trace. - Preserve original exceptions with
InnerExceptionwhen wrapping. - Use
using,await using, orfinallyfor cleanup. - Avoid exceptions for normal validation or parsing flows.
- Centralize exception handling in ASP.NET Core APIs.
- Log exceptions with structured context.
- Do not leak internal exception details to end users.
- Treat cancellation separately from failure.
- Write tests for expected exception behavior.