Skip to content

~ / blog / csharp-exception-handling

C# Exceptions: throw vs throw ex and Other Ways to Lose a Stack Trace

C# 11 min read

The null handling post ended on failing fast with a specific exception. This post is about what happens next: how exceptions travel, how to catch them without destroying the evidence, and, the part most codebases get wrong, when not to catch them at all. The single highest-value thing here is the difference between throw; and throw ex;, which is one character and the entire stack trace.

What an exception actually is

An exception is an object describing a failure, plus a mechanism that unwinds the call stack until someone handles it.

try
{
    var book = repository.Get(id);
}
catch (BookNotFoundException ex)      // most specific first
{
    logger.LogWarning(ex, "Book {Id} not found", id);
    return NotFound();
}
catch (SqlException ex)
{
    logger.LogError(ex, "Database failure");
    throw;                            // rethrow, see below
}
finally
{
    stopwatch.Stop();                 // runs whether or not anything threw
}

The mechanics worth knowing:

  • Catch blocks are checked in order, so specific types must come before general ones. catch (Exception) first makes everything below it unreachable, and the compiler says so.
  • finally almost always runs: on success, on a handled exception, on an unhandled one propagating past, and on an early return. It does not run on Environment.FailFast, a stack overflow, or process termination.
  • An unhandled exception terminates the process. In ASP.NET Core, middleware catches it first and turns it into a 500, but the principle holds.

The important properties on every exception:

ex.Message           // human-readable description
ex.StackTrace        // where it was thrown, frame by frame, the actual diagnostic value
ex.InnerException    // the original exception, when this one wraps another
ex.Data              // a dictionary for extra context
ex.ToString()        // message + stack trace + all inner exceptions, recursively

Log ex, not ex.Message. Every logging framework formats the full exception including the stack trace and inner chain; logging just the message throws away everything that would let you find the bug.

throw vs throw ex

This is the one to get right.

catch (Exception ex)
{
    throw ex;         // WRONG: resets the stack trace to this line
}
catch (Exception ex)
{
    throw;            // CORRECT: preserves the original stack trace
}

throw ex; rethrows the object but resets its origin point to the current line. The trace now says the exception came from your catch block, and every frame between the real cause and here is gone. You lose the file and line number of the actual failure, the only information that matters at 3am.

throw; (bare, no operand) rethrows the exception in flight with its stack trace intact, appending the current frame.

Two follow-ups worth knowing:

To add context, wrap rather than rethrow. Preserve the original as InnerException:

catch (SqlException ex)
{
    throw new BookRepositoryException($"Failed loading book {id}", ex);
}

Both stack traces survive, and ToString() prints the whole chain.

To rethrow an exception you stored somewhere (from a field, a task, a collection), use ExceptionDispatchInfo, which is the only way to preserve a trace across that gap:

ExceptionDispatchInfo.Capture(ex).Throw();

When not to catch

More damage is done by over-catching than by under-catching. Three anti-patterns:

The empty catch. This is the worst line of code in C#:

try { DoSomething(); } catch { }      // never do this

The program is now in an unknown state and nobody will ever know why the data is wrong. If you truly expect and accept a specific failure, catch that specific type and write a comment saying why it is safe.

Catch, log, rethrow at every layer. Produces the same exception in the log five times and buries the real one:

catch (Exception ex) { logger.LogError(ex, "failed"); throw; }   // in every method

Log once, at the boundary where you decide what to do about it. In ASP.NET Core that boundary is exception-handling middleware or a global filter, and lower layers should simply let exceptions propagate.

Catching Exception to keep going. Only appropriate at a genuine top-level boundary: a request handler, a message consumer, a background service loop. Anywhere else, catch what you can actually handle.

The rule: catch an exception only if you can do something meaningful about it: retry it, substitute a default, translate it for a caller, or convert it into a user-facing response at the boundary. Otherwise let it travel.

Do not use exceptions for control flow

Exceptions are expensive: throwing captures a stack trace and unwinds frames, costing on the order of microseconds each. That is irrelevant once, and ruinous in a loop over ten thousand rows.

More importantly, they are for the exceptional. “The user typed letters in a number field” is expected, so use the Try pattern:

if (!int.TryParse(input, out var price))
    return BadRequest("Price must be a number");
try { price = int.Parse(input); }               // slow and wrong-shaped for expected input
catch (FormatException) { return BadRequest(); }

The framework’s own naming tells you the intent: Parse throws because failure is unexpected; TryParse returns false because failure is routine. Provide the same pair on your own APIs when a failure is a normal outcome.

Custom exceptions

Write one when a caller might reasonably want to catch that specific failure:

public class BookNotFoundException : Exception
{
    public int BookId { get; }

    public BookNotFoundException(int bookId)
        : base($"Book {bookId} was not found.") => BookId = bookId;

    public BookNotFoundException(string message, Exception inner)
        : base(message, inner) { }
}

Conventions: name it ...Exception, derive from Exception (not ApplicationException, which was a design mistake and adds nothing), always offer a constructor taking an innerException, and put useful data on typed properties rather than only in the message string: ex.BookId is actionable, parsing the message is not.

Do not create a custom exception when a built-in one fits. Use ArgumentNullException, ArgumentOutOfRangeException, InvalidOperationException (object is in the wrong state), NotSupportedException and TimeoutException. Never throw Exception, SystemException or ApplicationException directly: callers would have to catch everything to catch yours.

Exception filters

when clauses (C# 6) let you decide whether to catch before the stack unwinds:

catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    await Task.Delay(backoff);
}
catch (SqlException ex) when (ex.Number is 1205 or -2)     // deadlock or timeout
{
    return await RetryAsync();
}

This is better than catching and rethrowing for two reasons. If the filter returns false, the exception continues as if your catch block did not exist, no stack trace disturbance at all. And because the filter runs before unwinding, a debugger breaking on the throw still shows the original frames and local variables. A filter is strictly better than catch (X ex) { if (!cond) throw; ... }.

A useful trick: a filter with a side effect that always returns false logs without catching.

catch (Exception ex) when (Log(ex)) { }        // Log returns false; exception keeps going

Exceptions and async

Two rules that will save you real debugging time; the mechanics are in the async post.

Exceptions from a Task surface when you await it, wrapped in an AggregateException if you use .Wait() or .Result instead. await unwraps to the original exception, which is one more reason never to block on a task.

async void exceptions cannot be caught by the caller. There is no task to carry the failure, so it is raised on the synchronisation context and usually crashes the process. Use async Task everywhere except event handlers, and put a try/catch inside those.

The same idea inside Java, Go and Rust

Error handling is one of the areas where languages disagree most, which makes the comparison genuinely instructive.

Java (Netflix, Android). Same model plus checked exceptions: a method must declare what it throws, and callers must handle or redeclare them. The intent was excellent; in practice it produced millions of catch (Exception e) { } blocks written to satisfy the compiler. C# deliberately omitted checked exceptions after watching that happen, and no major language has adopted them since. It is a good interview answer to know why.

Go (Google). No exceptions. Errors are ordinary return values checked at every call:

book, err := repo.Get(id)
if err != nil {
    return nil, fmt.Errorf("loading book %d: %w", id, err)
}

Verbose, and impossible to ignore accidentally. %w wrapping is Go’s InnerException. panic/recover exists but is reserved for genuinely unrecoverable situations, which is the discipline C# asks of you without enforcing it.

Rust. Result<T, E> is a value the compiler forces you to handle, and ? propagates it in one character. No hidden control flow, no stack unwinding on the normal path. This is the strongest version of the “result type” idea from the null post.

Python. Exceptions are idiomatic for control flow: StopIteration ends every for loop, and the culture is “easier to ask forgiveness than permission”. A direct contradiction of C#‘s guidance, and a reminder that this advice is about cost model, not universal truth: Python exceptions are cheap, .NET exceptions are not.

JavaScript (Meta). try/catch plus rejected promises, which is async exception handling with the same trap: an unhandled rejection is easy to create and easy to miss, exactly like async void.

Summary

  • Catch blocks are evaluated in order, so specific types come before general ones; finally runs on success, failure and early return.
  • throw ex; resets the stack trace to the rethrow line and destroys the evidence. throw; preserves it. Wrap with a new exception and an innerException when you want to add context.
  • Log the exception object, not ex.Message: the stack trace and inner chain are the diagnostic value.
  • An empty catch is the worst line in C#. Catch only what you can meaningfully handle; let everything else reach a boundary that logs once and responds.
  • Do not use exceptions for expected outcomes. Use the Try pattern; throwing is expensive and semantically wrong for routine failures.
  • Write a custom exception when callers might catch that specific failure. Derive from Exception, include an inner-exception constructor, and expose data as properties.
  • Exception filters (when) decide before unwinding, so they preserve the stack and the debugger’s view, and beat catch-and-rethrow.
  • await unwraps exceptions from tasks; async void exceptions escape everything and crash the process.

Interview questions and answers

1. What is the difference between throw and throw ex?

throw; rethrows the exception currently being handled and preserves its original stack trace. throw ex; throws the object again as if it originated at that line, resetting the stack trace and discarding every frame between the real failure and the catch block, which usually removes the only information that identifies the bug.

2. When does a finally block not run?

In normal operation it always runs: on success, on a handled exception, on one propagating through, and on an early return. It is skipped only when the process does not unwind: Environment.FailFast, a StackOverflowException, an uncatchable runtime failure, or the process being killed.

3. How do you add context to an exception without losing the original?

Throw a new exception and pass the original as the innerException argument. ToString() and every logging framework then print the full chain, so you get your context plus the original type, message and stack trace. Alternatively, use ExceptionDispatchInfo.Capture(ex).Throw() to rethrow a stored exception with its trace preserved.

4. What is wrong with catching Exception?

At most call sites you cannot meaningfully handle every possible failure, so catching everything hides bugs and leaves the program running in an unknown state. It is appropriate only at a genuine top-level boundary (a request pipeline, a message consumer, a background loop) where the job is to log once and produce a response.

5. Why should exceptions not be used for control flow?

Because throwing captures a stack trace and unwinds frames, which is orders of magnitude more expensive than a comparison, and because it obscures the normal path of the code. Expected outcomes should be expressed as return values (the TryParse pattern, or a result type), leaving exceptions for genuinely unexpected failures.

6. What is an exception filter and why is it better than catching and rethrowing?

A when (condition) clause on a catch block, evaluated before the stack unwinds. If it returns false the exception continues as though the catch block was not there, so the stack trace is untouched and the debugger still sees the original frames and locals. Catch-then-rethrow, by contrast, unwinds first and disturbs that state.

7. What should a custom exception look like?

Named with an Exception suffix, derived directly from Exception, with a constructor taking a message, one taking a message and an inner exception, and any useful data exposed as typed properties. It should exist only when a caller would plausibly catch that specific type; otherwise use a built-in exception.

8. What is the difference between ArgumentException and InvalidOperationException?

ArgumentException and its derivatives say the caller passed a bad value, wrong, null, or out of range. InvalidOperationException says the arguments were fine but the object is in the wrong state for the call, such as reading from a closed connection. The distinction tells the caller whether to fix the input or the sequence of calls.

9. How do exceptions behave in async methods?

An exception thrown inside an async Task method is captured and stored on the returned task, and rethrown when the task is awaited. Blocking with .Result or .Wait() instead wraps it in an AggregateException. An async void method has no task to carry the exception, so it is raised on the synchronisation context and typically terminates the process.

10. Why did C# choose not to have checked exceptions?

Java’s experience showed that forcing callers to declare or handle every exception type leads to defensive boilerplate and empty catch blocks written purely to satisfy the compiler, and that it makes interfaces brittle because adding a thrown type is a breaking change. C# opted for unchecked exceptions and documentation instead, and no mainstream language has adopted checked exceptions since.

# tags csharpexceptionserror-handlingdotnetinterview-questions
$ share linkedin x / twitter

Tayyab Shahid, Senior Software Engineer in London

Tayyab Shahid

Senior Software Engineer & .NET Consultant · London, UK

about github linkedin

§ related posts

Building something like this?

I take on freelance .NET projects, consulting, and senior roles.

get in touch

← cd ~/blog

CV