Skip to content

Exception Handling Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

C# exception handling interview questions — throw vs throw ex, stack trace preservation, custom exceptions, AggregateException, and global handlers.

Read the in-depth guideException Handling Patterns in C#(opens in new tab)
15 of 15

All exceptions in .NET inherit from System.Exception. The hierarchy has two main branches: SystemException (runtime conditions, usually unrecoverable) and ApplicationException (deprecated base for user-defined exceptions — not used in modern code).

// Simplified hierarchy:
// Exception
// SystemException
// NullReferenceException
// InvalidOperationException
// ArgumentException
// ArgumentNullException
// ArgumentOutOfRangeException
// IndexOutOfRangeException
// IOException
// FileNotFoundException
// ArithmeticException
// DivideByZeroException
// OutOfMemoryException      ← usually unrecoverable
// StackOverflowException    ← process terminates
// ApplicationException        ← deprecated, avoid

// Catch the most specific type you can handle:
try
{
    File.ReadAllText("data.txt");
}
catch (FileNotFoundException ex)
{
    Console.WriteLine($"File missing: {ex.FileName}");
}
catch (IOException ex)
{
    Console.WriteLine($"I/O error: {ex.Message}");
}
catch (Exception ex)
{
    // Last resort — log and rethrow, or rethrow as-is
    logger.LogError(ex, "Unexpected error");
    throw;
}

Rule of thumb: Catch the most specific exception type you can meaningfully handle. Avoid catching Exception unless you log and rethrow. Never catch OutOfMemoryException or StackOverflowException — the process state is corrupt.

throw (bare) re-throws the current exception, preserving the original stack trace. throw ex throws the caught exception as if it were new, replacing the stack trace with the current location — destroying information that is critical for debugging.

void Outer()
{
    try { Inner(); }
    catch (Exception ex) { throw ex; } // BAD — loses Inner's stack trace
}

void Inner()
{
    throw new InvalidOperationException("failed here");
}

// With 'throw ex': stack trace points to Outer (the catch block)
// With 'throw':    stack trace points to Inner (where it actually failed)

// Correct: preserve the original stack trace
void OuterCorrect()
{
    try { Inner(); }
    catch (Exception ex)
    {
        logger.LogError(ex, "Error in Inner");
        throw; // re-throw — stack trace unchanged
    }
}

// Wrapping exceptions — preserve original as InnerException:
void OuterWrapped()
{
    try { Inner(); }
    catch (InvalidOperationException ex)
    {
        throw new ApplicationException("High-level failure", ex); // ex = InnerException
    }
}
// Both the original and wrapping exceptions are in the chain

Rule of thumb: Always use bare throw to re-throw. If you need to wrap an exception in a higher-level one, pass the original as innerException. Examine the stack trace whenever you see throw ex in code review — it is almost always a bug.

The finally block always runs after try/catch, whether or not an exception was thrown, and whether or not the exception was handled. It is used for cleanup (closing files, releasing locks, etc.).

FileStream? fs = null;
try
{
    fs = File.OpenRead("data.txt");
    Process(fs);
}
catch (IOException ex)
{
    Console.WriteLine(ex.Message);
}
finally
{
    fs?.Close(); // always runs — even if Process throws, even if catch throws
    Console.WriteLine("Cleaned up");
}

// finally does NOT run in these cases:
// 1. StackOverflowException — the CLR tears down the process
// 2. Environment.FailFast() — immediate process termination
// 3. Infinite loop or deadlock before reaching finally

// Modern preference: 'using' statement replaces try/finally for IDisposable:
using var stream = File.OpenRead("data.txt"); // Dispose called automatically
Process(stream);
// No explicit finally needed — cleaner and exception-safe

Rule of thumb: Use using / using var for IDisposable objects instead of manual try/finally — it generates the same IL but is far more readable. Reserve explicit finally for cleanup that isn't covered by IDisposable.

A custom exception should inherit from Exception (not ApplicationException), provide the standard constructors, and include domain-specific context.

// Well-designed custom exception:
public class OrderValidationException : Exception
{
    public int OrderId { get; }
    public string Field { get; }

    // Standard constructors — needed for serialisation and chaining:
    public OrderValidationException() { }

    public OrderValidationException(string message)
        : base(message) { }

    public OrderValidationException(string message, Exception innerException)
        : base(message, innerException) { }

    // Domain-specific constructor:
    public OrderValidationException(int orderId, string field, string message)
        : base(message)
    {
        OrderId = orderId;
        Field   = field;
    }
}

// Usage:
void ValidateOrder(Order order)
{
    if (order.Total < 0)
        throw new OrderValidationException(order.Id, nameof(order.Total),
            $"Order {order.Id}: Total cannot be negative.");
}

try
{
    ValidateOrder(bad);
}
catch (OrderValidationException ex)
{
    logger.LogWarning("Validation failed for order {OrderId}, field {Field}: {Message}",
        ex.OrderId, ex.Field, ex.Message);
}

Rule of thumb: Create custom exceptions when callers need to distinguish your error from others programmatically (i.e., they would catch it specifically). Add properties for context that logs and handlers need. Do not add custom exceptions just for error messages — InvalidOperationException with a good message is often enough.

AggregateException wraps one or more inner exceptions. The TPL (Task Parallel Library) and Task.WhenAll use it to collect multiple failures that happened simultaneously.

var t1 = Task.Run(() => throw new InvalidOperationException("err1"));
var t2 = Task.Run(() => throw new ArgumentException("err2"));

try
{
    await Task.WhenAll(t1, t2);
}
catch (Exception ex)
{
    // await re-throws only the FIRST inner exception:
    Console.WriteLine(ex.GetType().Name);  // InvalidOperationException
}

// To inspect ALL exceptions:
var tasks = new[] { t1, t2 };
try { await Task.WhenAll(tasks); }
catch { }
foreach (var t in tasks.Where(t => t.IsFaulted))
{
    AggregateException? agg = t.Exception;
    foreach (var inner in agg!.InnerExceptions)
        Console.WriteLine(inner.Message); // err1, then err2
}

// Parallel.ForEach also throws AggregateException:
try
{
    Parallel.ForEach(items, item => ProcessItem(item));
}
catch (AggregateException agg)
{
    // Handle or rethrow individual exceptions:
    agg.Handle(ex =>
    {
        if (ex is TimeoutException) { logger.LogWarning(ex, "Timeout"); return true; }
        return false; // unhandled — re-thrown
    });
}

Rule of thumb: When awaiting a Task.WhenAll, inspect the individual Task .Exception properties if you need to see every failure. The single caught exception from await only surfaces the first one.

A when clause on a catch block adds a boolean condition. If it evaluates to false, the catch is skipped (as if it didn't exist) and the stack is not unwound — other handlers further up the call stack can catch it instead.

// Catch only specific HTTP status codes:
try
{
    var response = await client.GetAsync(url);
    response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
    return null; // 404 — treat as "not found", not an error
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
    throw new UnauthorizedException("Token expired", ex);
}
// Other HttpRequestExceptions propagate normally

// Logging without catching — the filter runs BEFORE the stack unwinds:
catch (Exception ex) when (Log(ex)) { } // Log() always returns false — just a side effect

bool Log(Exception ex)
{
    logger.LogError(ex, "Unhandled exception");
    return false; // returning false means the filter doesn't match; exception propagates
}

// Distinguish transient vs permanent errors:
catch (SqlException ex) when (IsTransient(ex))
{
    await RetryAsync();
}

Rule of thumb: Use when filters to narrow catch scope without losing the original exception or unwinding the stack prematurely. Use the false-returning filter trick for "log and rethrow" without changing stack trace.

ExceptionDispatchInfo captures an exception — including its stack trace — and allows you to re-throw it later, preserving the original stack trace, even from a different thread or context. It is what await uses internally to propagate exceptions from Tasks.

ExceptionDispatchInfo? captured = null;

Thread t = new Thread(() =>
{
    try { throw new InvalidOperationException("from thread"); }
    catch (Exception ex)
    {
        // Capture preserves the original stack trace:
        captured = ExceptionDispatchInfo.Capture(ex);
    }
});
t.Start();
t.Join();

// Re-throw on the calling thread — original stack trace is preserved!
captured?.Throw();
// Stack trace shows the original thread's call stack, not this point

// Real use case: manual async exception propagation
ExceptionDispatchInfo? error = null;
var task = Task.Run(() =>
{
    try { RiskyWork(); }
    catch (Exception ex) { error = ExceptionDispatchInfo.Capture(ex); }
});
await task;
error?.Throw(); // same as 'await task' but explicit

ExceptionDispatchInfo also exposes .SourceException to inspect the captured exception without re-throwing.

Rule of thumb: You rarely need ExceptionDispatchInfo directly — async/await handles it for you. Use it when marshalling exceptions between threads manually, or building custom async/sync bridges.

ASP.NET Core provides multiple mechanisms for global exception handling, ranging from simple middleware to fine-grained problem-details responses.

// 1. UseExceptionHandler middleware (built-in):
app.UseExceptionHandler(errorApp =>
{
    errorApp.Run(async context =>
    {
        var feature = context.Features.Get<IExceptionHandlerFeature>();
        var ex = feature?.Error;
        context.Response.StatusCode = 500;
        context.Response.ContentType = "application/json";
        await context.Response.WriteAsJsonAsync(new { error = ex?.Message });
    });
});

// 2. IExceptionHandler interface (.NET 8+) — preferred:
public class GlobalExceptionHandler : IExceptionHandler
{
    private readonly ILogger<GlobalExceptionHandler> _logger;
    public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
        => _logger = logger;

    public async ValueTask<bool> TryHandleAsync(
        HttpContext context, Exception exception, CancellationToken ct)
    {
        _logger.LogError(exception, "Unhandled exception");

        var statusCode = exception switch
        {
            NotFoundException => StatusCodes.Status404NotFound,
            UnauthorizedException => StatusCodes.Status401Unauthorized,
            _ => StatusCodes.Status500InternalServerError
        };

        context.Response.StatusCode = statusCode;
        await context.Response.WriteAsJsonAsync(
            new ProblemDetails { Title = exception.Message, Status = statusCode }, ct);

        return true; // exception handled
    }
}

// Register:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();

Rule of thumb: Use .NET 8 IExceptionHandler for structured, testable global error handling. Map domain exceptions (NotFoundException, ValidationException) to appropriate HTTP status codes here rather than scattering status code decisions across controllers.

There are four ways to rethrow or propagate an exception, each with different semantics.

try { DoWork(); }

// 1. bare throw — preserve original exception and stack trace (almost always correct)
catch (Exception ex) { logger.LogError(ex, "..."); throw; }

// 2. throw new — wrap the original as InnerException; adds context, changes type
catch (SqlException ex)
    throw new RepositoryException("DB operation failed", ex);  // inner preserved

// 3. throw ex — AVOID: re-throws same exception but clears stack trace
catch (Exception ex) { throw ex; } // stack trace is gone

// 4. ExceptionDispatchInfo.Capture — capture + rethrow across thread/context boundary
ExceptionDispatchInfo? captured = null;
await Task.Run(() =>
{
    try { RiskyWork(); }
    catch (Exception ex) { captured = ExceptionDispatchInfo.Capture(ex); }
});
captured?.Throw(); // same exception, original stack trace, different thread

// Pattern comparison:
// bare throw     — same exception, same stack trace, same type
// throw new(inner) — new exception type, InnerException = original, new stack trace
// ExceptionDispatchInfo — same exception, original stack trace, across threads

// When to wrap:
void LoadConfig()
{
    try { File.ReadAllText("config.json"); }
    catch (IOException ex)
        // translate low-level exception to domain exception:
        throw new ConfigurationException("Cannot load configuration", ex);
}

Rule of thumb: Default to bare throw. Wrap with a new exception type when you want to translate a low-level technical exception into a domain-meaningful one — always pass the original as innerException. Only use ExceptionDispatchInfo when crossing thread boundaries manually.

By default, C# arithmetic is unchecked — integer overflow silently wraps around. The checked keyword / block makes overflow throw OverflowException.

int max = int.MaxValue; // 2,147,483,647

// Unchecked (default) — wraps silently:
int overflow = max + 1; // -2,147,483,648 — no exception!

// Checked — throws OverflowException:
try
{
    int safe = checked(max + 1);
}
catch (OverflowException)
{
    Console.WriteLine("Overflow detected");
}

// Checked block for multiple expressions:
checked
{
    int a = int.MaxValue;
    int b = a + 1; // throws
}

// unchecked block — explicitly opt out in a checked project:
unchecked
{
    int wrapped = int.MaxValue + 1; // -2,147,483,648 — intentional wrap
}

// Real use: computing array indices, hash codes, byte conversions:
// Hash code computation often intentionally overflows:
unchecked
{
    int hash = 17;
    hash = hash * 31 + value.GetHashCode(); // overflow is fine here
    return hash;
}

C# projects can be compiled with /checked+ to enable checked arithmetic globally. Most .NET projects don't, favouring performance. decimal arithmetic is always checked. Floating-point (float, double) uses IEEE 754 rules — no OverflowException.

Rule of thumb: Use checked when computing values that feed into array indices, counts, or financial calculations where silent overflow would be a bug. Use unchecked explicitly in hash code computations where overflow is intentional.

Exception handling mistakes compound in production — swallowed exceptions, destroyed stack traces, and overly broad catches all make incidents harder to resolve.

// DON'T: swallow exceptions silently
try { DoWork(); }
catch (Exception) { } // exception disappears — impossible to debug

// DO: log and rethrow, or handle specifically
try { DoWork(); }
catch (Exception ex)
{
    logger.LogError(ex, "DoWork failed");
    throw; // preserve stack trace
}

// DON'T: throw ex (destroys stack trace)
catch (Exception ex) { throw ex; }

// DO: bare throw
catch (Exception ex) { logger.LogError(ex, "..."); throw; }

// DON'T: use exceptions for control flow in hot paths
try { return int.Parse(input); }
catch { return 0; } // exceptions are slow for expected failure

// DO: use TryParse for expected failure
int.TryParse(input, out int result); // returns false, no exception

// DON'T: catch Exception in library code unless you rethrow
public int Calculate(string input)
{
    try { return int.Parse(input); }
    catch (Exception) { return -1; } // caller doesn't know WHY it failed
}

// DO: let exceptions propagate; only catch what you can handle
public int Calculate(string input) => int.Parse(input); // let FormatException surface

// DO: validate input at boundaries to avoid exceptions in the first place
public void SetQuantity(int qty)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(qty);
    _quantity = qty;
}

Rule of thumb: Exceptions should be exceptional — unexpected failures, not expected control flow. Always log before rethrowing. Use TryXxx patterns for expected failure cases. Validate inputs at method boundaries to prevent exceptions from propagating unnecessarily.

A throw expression (C# 7) allows throw to appear in expression contexts — ternary operators, null-coalescing operators, and expression-bodied members — where previously only throw statements were allowed.

// Null-coalescing throw (very common pattern):
public class OrderService
{
    private readonly IOrderRepository _repo;

    // Old style — required extra if statement:
    public OrderService(IOrderRepository repo)
    {
        if (repo == null) throw new ArgumentNullException(nameof(repo));
        _repo = repo;
    }

    // C# 7 throw expression — single line:
    public OrderService(IOrderRepository repo)
        => _repo = repo ?? throw new ArgumentNullException(nameof(repo));
}

// In ternary operator:
string GetName(Customer? c)
    => c != null ? c.Name : throw new ArgumentNullException(nameof(c));

// In expression-bodied property:
private string _name = "";
public string Name
{
    get => _name;
    set => _name = value?.Trim() ?? throw new ArgumentNullException(nameof(value));
}

// ArgumentNullException.ThrowIfNull (.NET 6+) — even cleaner:
public OrderService(IOrderRepository repo)
{
    ArgumentNullException.ThrowIfNull(repo);
    _repo = repo;
}

Rule of thumb: Use ?? throw new ArgumentNullException(nameof(param)) for null-guard assignments. In .NET 6+, prefer ArgumentNullException.ThrowIfNull(param) and ArgumentException.ThrowIfNullOrEmpty(param) in method bodies — they are more readable and generate slightly better IL.

Throwing an exception is expensive because the CLR must capture the stack trace (walking the call stack, allocating strings for each frame) and allocate the exception object on the heap. In hot-path code this cost is significant.

// Expensive — exception used for expected failure (parsing user input):
int ParseBad(string input)
{
    try { return int.Parse(input); }
    catch (FormatException) { return -1; } // allocates exception, walks stack
}

// Cheap — TryParse avoids exception entirely:
int ParseGood(string input)
    => int.TryParse(input, out int result) ? result : -1; // no exception, no allocation

// Pattern: Try-method convention for expected failure:
public bool TryResolve(string key, out string? value)
{
    // returns false on failure — caller decides how to handle it
    return _cache.TryGetValue(key, out value);
}

// Result<T> pattern (alternative to exceptions for expected errors):
public record Result<T>(T? Value, string? Error, bool IsSuccess);

Result<Order> LoadOrder(int id)
{
    var order = _repo.Find(id);
    return order is null
        ? new Result<Order>(null, $"Order {id} not found", false)
        : new Result<Order>(order, null, true);
}

var result = LoadOrder(42);
if (!result.IsSuccess) Console.WriteLine(result.Error);

// Note: exceptions are ONLY expensive when thrown.
// try/catch blocks with no exception have near-zero overhead on modern JIT.

Rule of thumb: Reserve exceptions for unexpected, exceptional failures — not for expected outcomes like "user typed bad input" or "record not found." For expected failures, use the TryXxx pattern or a result type. Profile before assuming exceptions are your bottleneck; the overhead only materialises when they are actually thrown.

When you catch an exception and throw a new one, the original exception should be passed as the inner exception — preserving the root cause while providing higher-level context. InnerException forms a chain that can be walked to find the original failure.

// Low-level method throws a technical exception:
void ReadConfig(string path)
{
    // Throws FileNotFoundException if missing
    var text = File.ReadAllText(path);
    // Throws JsonException if malformed
    JsonSerializer.Deserialize<Config>(text);
}

// Mid-level method wraps and adds context:
Config LoadConfig(string path)
{
    try { ReadConfig(path); }
    catch (FileNotFoundException ex)
        throw new ConfigurationException($"Config file not found: {path}", ex);
    catch (JsonException ex)
        throw new ConfigurationException($"Config file is malformed: {path}", ex);
    return new Config(); // unreachable after throw
}

// Caller walks the chain:
try { LoadConfig("appsettings.json"); }
catch (ConfigurationException ex)
{
    Console.WriteLine($"Error: {ex.Message}");
    Console.WriteLine($"Caused by: {ex.InnerException?.Message}");

    // Walk the full chain:
    Exception? current = ex;
    while (current != null)
    {
        Console.WriteLine($"  {current.GetType().Name}: {current.Message}");
        current = current.InnerException;
    }
}

// Exception.GetBaseException() — returns the deepest InnerException:
Exception root = ex.GetBaseException();
Console.WriteLine(root.Message); // the original FileNotFoundException message

Rule of thumb: Always pass the original exception as innerException when wrapping. This preserves the complete failure chain for logging and debugging. Never discard the original exception — if you don't need to wrap it, use bare throw instead.

Guard clauses validate preconditions at the entry point of a method and throw immediately — before any work begins. This produces clearer error messages, fails fast, and avoids propagating invalid state deep into business logic where the original cause is harder to diagnose.

// Without guard clauses — NullReferenceException buried somewhere in logic:
public decimal CalculateTotal(Order order)
{
    // order might be null — crash happens inside the loop, far from the call site
    return order.Items.Sum(i => i.Price * i.Quantity);
}

// With guard clauses — fail immediately with a clear message:
public decimal CalculateTotal(Order order)
{
    ArgumentNullException.ThrowIfNull(order);            // .NET 6+
    ArgumentNullException.ThrowIfNull(order.Items);

    return order.Items.Sum(i => i.Price * i.Quantity);
}

// .NET 6+ guard helpers (prefer over manual checks):
ArgumentNullException.ThrowIfNull(value);
ArgumentException.ThrowIfNullOrEmpty(str);
ArgumentException.ThrowIfNullOrWhiteSpace(str);
ArgumentOutOfRangeException.ThrowIfNegative(count);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);
ArgumentOutOfRangeException.ThrowIfGreaterThan(index, max);

// For expected failure (not a programming error), prefer TryXxx or Result:
// Guard clause = programmer used the API wrong (throw ArgumentException)
// Expected failure = valid input that yields no result (return false or Result)

// Example: ID lookup is a valid scenario; bad argument is not
public bool TryGetUser(int id, out User? user)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(id); // guard: must be positive
    user = _store.Find(id); // null is a valid "not found" result
    return user is not null;
}

Rule of thumb: Validate all public method arguments with guard clauses and throw ArgumentException (or its subclasses) immediately. Distinguish between programming errors (bad arguments — throw) and expected absence (not found — return false or null). Guard clauses at the top of a method act as executable documentation of its preconditions.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel