DEV_NET_CORE
GET_STARTED
.NETC# Language Foundations

Exceptions

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:

  • ArgumentException
  • ArgumentNullException
  • ArgumentOutOfRangeException
  • InvalidOperationException
  • NotSupportedException
  • UnauthorizedAccessException
  • FileNotFoundException
  • IOException
  • TimeoutException
  • OperationCanceledException

Example:

Code
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:

KeywordPurpose
tryWraps code that may throw an exception
catchHandles a specific exception type
finallyRuns cleanup code whether an exception occurs or not
throwThrows a new exception or rethrows an existing one
whenAdds a filter condition to a catch block

Example:

Code
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:

Code
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:

ScenarioRecommended Exception
Required argument is nullArgumentNullException
Argument value is outside valid rangeArgumentOutOfRangeException
Argument value is invalid but not range-basedArgumentException
Object state does not allow the operationInvalidOperationException
Operation is not supported by this implementationNotSupportedException
Feature is intentionally not implemented yetNotImplementedException
Operation is canceledOperationCanceledException
Access is deniedUnauthorizedAccessException
A timeout occursTimeoutException

Example:

Code
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:

Code
throw new InvalidOperationException("The order has already been submitted.");

Use guard clauses near the start of a method for invalid arguments:

Code
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:

Code
public void CreateUser(string username)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(username);

    // Create user...
}

Avoid intentionally throwing overly broad or runtime-reserved exceptions such as:

Code
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:

Code
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:

Code
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:

Code
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:

Code
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:

Code
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:

Code
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:

Code
try
{
    await CallExternalApiAsync();
}
catch (HttpRequestException ex) when (ex.Message.Contains("429"))
{
    Console.WriteLine("Rate limit was reached.");
}

Another example:

Code
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:

Code
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:

Code
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:

Code
await using var connection = await OpenConnectionAsync();
// Use connection...

Exceptions and using

using helps ensure resources are released even when exceptions occur.

Example:

Code
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:

Code
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:

Code
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:

Code
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:

Code
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 Task methods are observed when awaited.
  • Exceptions in async void methods 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 .Result can wrap exceptions in AggregateException and may cause deadlocks in some application types.

Prefer:

Code
await DoWorkAsync();

Avoid:

Code
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:

Code
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:

Code
public async Task ProcessAsync(CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();

    await Task.Delay(1000, cancellationToken);
}

When handling exceptions, avoid treating cancellation as a normal failure.

Example:

Code
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:

Code
public Customer GetCustomer(Guid id)
{
    return _repository.Find(id)
        ?? throw new CustomerNotFoundException($"Customer '{id}' was not found.");
}

Result pattern example:

Code
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:

SituationBetter approach
Programming bugException
Invalid method argumentException
Infrastructure failureException
Expected validation errorValidation result
User entered invalid form dataValidation result
Optional missing valuenull, bool, Try pattern, or Result
Domain rule failureDepends on architecture; often Result or domain-specific exception

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:

Code
public bool IsNumber(string input)
{
    try
    {
        int.Parse(input);
        return true;
    }
    catch
    {
        return false;
    }
}

Better:

Code
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:

Code
app.UseExceptionHandler();

builder.Services.AddProblemDetails();

A typical production API maps exceptions to appropriate HTTP responses:

ExceptionHTTP response
Validation exception400 Bad Request
Unauthorized access401 Unauthorized or 403 Forbidden
Not found exception404 Not Found
Conflict exception409 Conflict
Unhandled exception500 Internal Server Error

Example custom middleware concept:

Code
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:

Code
_logger.LogError(
    ex,
    "Failed to process order {OrderId} for customer {CustomerId}.",
    orderId,
    customerId);

Bad:

Code
_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 Exception everywhere
  • Swallowing exceptions silently
  • Using throw ex; instead of throw;
  • 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 .Result or .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.ThrowIfNull and related guard helpers when appropriate.
  • Catch only exceptions you can handle meaningfully.
  • Use throw; to rethrow and preserve stack trace.
  • Preserve original exceptions with InnerException when wrapping.
  • Use using, await using, or finally for 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.

Interview Practice

PreviousCommon BCL Types in C#Next UpNullable reference C#