Rethrowing correctly in .NET: stack trace preservation, wrapping with inner exceptions, ExceptionDispatchInfo, throw expressions and guard helpers.
throw; rethrows the current exception and keeps its original stack trace. throw ex; throws the same object as if it were new, so the stack trace is reset to the line of throw ex, and you lose where it really failed. Analyzer CA2200 flags it. To add context, throw a new exception with the original as InnerException. To rethrow an exception you captured earlier, for example from another thread, use ExceptionDispatchInfo.Capture(ex).Throw(), which keeps the original trace and appends the new one. Throw expressions (x ?? throw new ...) and guard helpers such as ArgumentNullException.ThrowIfNull keep validation short.
throw; rethrows the current exception and preserves its original stack trace. throw ex; throws the same exception object as if it were new from this line, which resets the stack trace to the rethrow point and hides where the error really happened. Then I'd cover rethrowing across threads with ExceptionDispatchInfo, and the modern throwing conveniences: throw expressions and ThrowIf helpers.
public Order Load(int id)
{
try
{
return _repository.Get(id); // real failure deep inside, e.g. line 87 of SqlOrderRepository
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load {OrderId}", id);
throw ex; // stack trace now starts HERE - SqlOrderRepository frames are gone
// throw; // stack trace still points to SqlOrderRepository line 87
}
}
With throw ex; the logged/returned stack trace shows OrderService.Load as the origin. In production logs you lose the frames that tell you which query, which parser, which line failed — often the only information you have.
throw; is only valid inside a catch block.throw ex; is flagged by analyser CA2200 ("Rethrow to preserve stack details").throw new XException("context", ex); is fine — it's a new exception, and the original is preserved as InnerException with its own stack trace.throw ex loses informationA .NET exception's stack trace is captured as it propagates — the runtime records frames while unwinding from the throw point. Throwing an exception object (throw ex) tells the runtime "this is a new throw from here", so it resets the captured trace and starts recording again from the current frame. throw; (IL rethrow) tells the runtime to continue propagating the in-flight exception, appending to the existing trace.
ExceptionDispatchInfoSometimes you capture an exception in one place and need to rethrow it elsewhere — another thread, a later callback, after cleanup:
ExceptionDispatchInfo? captured = null;
var worker = new Thread(() =>
{
try { DoWork(); }
catch (Exception ex) { captured = ExceptionDispatchInfo.Capture(ex); }
});
worker.Start();
worker.Join();
captured?.Throw(); // rethrows the ORIGINAL exception with its original stack trace
// plus a "--- End of stack trace from previous location ---" marker
ExceptionDispatchInfo.Capture(ex) snapshots the stack trace; .Throw() rethrows preserving it and appending the new frames.ExceptionDispatchInfo.Throw(ex) (static) captures and throws in one call.ExceptionDispatchInfo.SetCurrentStackTrace(ex) (.NET 5) gives an exception a stack trace without throwing it — useful when you store an exception in a task.await does: when a task faults, await rethrows the stored exception using ExceptionDispatchInfo, which is why async stack traces show "End of stack trace from previous location" sections instead of being lost.[StackTraceHidden] keeps traces clean.throw can be used as an expression in specific places: the right side of ??, the branches of ?:, and expression-bodied members:
_name = name ?? throw new ArgumentNullException(nameof(name));
var order = await _repo.FindAsync(id, ct) ?? throw new OrderNotFoundException(id);
public string Code => _code ?? throw new InvalidOperationException("Not initialised.");
int Parse(string s) => int.TryParse(s, out var n) ? n : throw new FormatException($"Bad number '{s}'");
They keep guard logic concise and make fields readonly-friendly in constructors.
ThrowIf helpers (.NET 6–8)ArgumentNullException.ThrowIfNull(order); // .NET 6
ArgumentException.ThrowIfNullOrEmpty(customerId); // .NET 7
ArgumentException.ThrowIfNullOrWhiteSpace(email); // .NET 8
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity); // .NET 8
ArgumentOutOfRangeException.ThrowIfGreaterThan(pageSize, 100); // .NET 8
ObjectDisposedException.ThrowIf(_disposed, this); // .NET 7
Advantages:
[CallerArgumentExpression], so ThrowIfNull(order) reports "order" without nameof.An intermittent NullReferenceException in an invoicing service took days to diagnose because every layer used catch (Exception ex) { _logger.LogError(ex, ...); throw ex; }. Application Insights showed the exception originating in the controller — the one place it couldn't have come from. Changing the rethrows to throw; (and removing most of those catch blocks, letting the global handler log once) revealed the real source on the next occurrence: a mapper reading a null tax region for a specific customer type. We enabled CA2200 as an error so throw ex; can't come back.
A classic question that quickly separates people who have debugged production incidents from those who have not.
ExceptionDispatchInfo, and logging once at the boundary.throw ex and throw are the same, wraps without inner exceptions, or logs at every layer.When an exception is thrown, the runtime doesn't immediately build a string. During the second pass of exception handling (unwinding), it records each frame (method handle + IL offset/native IP) into the exception's internal _stackTrace array. Exception.StackTrace formats it lazily into text (using PDBs for file and line numbers if available).
throw (from throw ex; or throw new ...): resets the captured trace, starts fresh at the current frame.rethrow (from throw;): keeps the existing frames and continues appending as the exception propagates further.Note: even throw; loses one detail — the line number of the original throw within the same method can be replaced by the rethrow line if the throw and the catch are in the same method (the frame is recorded once). Extracting the throwing code into a separate method or using exception filters avoids that edge case.
ExceptionDispatchInfo internalsCapture(ex) copies the exception's current stack trace data and Watson buckets into the ExceptionDispatchInfo object. Throw() restores that captured state into the exception and throws it; the runtime then appends new frames after an "End of stack trace from previous location" marker. The same exception object is thrown (so catch blocks still see the original type and data).
Where it's used in the BCL:
await on a faulted Task (TaskAwaiter.GetResult → ThrowForNonSuccess → ExceptionDispatchInfo.Throw).Task.Wait/Result wrap in AggregateException instead (no dispatch info), which is one reason they're worse.Lazy<T> caches and rethrows exceptions from the factory.MethodInfo.Invoke wraps in TargetInvocationException; code that unwraps it should use ExceptionDispatchInfo.Capture(tie.InnerException!).Throw() to keep the inner trace.x ?? throw new E() compiles to a branch: if x is null, construct and throw. Because throw is an expression of no type (it never produces a value), it can be used where any type is expected. It cannot be used as a standalone expression statement other than the normal throw statement, and not in arbitrary positions like method arguments (except within a conditional/coalescing expression).
A method containing a throw is typically not inlined by the JIT (throw sites increase code size and are treated as cold). Patterns like:
public static void ThrowIfNull([NotNull] object? arg, [CallerArgumentExpression(nameof(arg))] string? paramName = null)
{
if (arg is null) Throw(paramName);
}
[DoesNotReturn]
private static void Throw(string? paramName) => throw new ArgumentNullException(paramName);
keep the check small and inlinable, and move the throw into a separate method marked [DoesNotReturn] (so nullable analysis knows code after it is unreachable). This is why the BCL's ThrowIf... helpers are both readable and fast, and why custom guard libraries should follow the same shape.
[CallerArgumentExpression] (C# 10)The compiler passes the source text of the argument expression as a string:
ArgumentOutOfRangeException.ThrowIfNegative(order.Lines.Count - 1);
// paramName = "order.Lines.Count - 1"
It's also used by Debug.Assert-style APIs and test assertion libraries to show the failing expression.
OrderService.LoadAsync(Int32 id) rather than <LoadAsync>d__5.MoveNext().<DebugType>embedded</DebugType>) so production traces include file and line numbers.throw ex does.[StackTraceHidden] (.NET 6) hides helper frames from traces.throw ex; and with throw;, and compare ex.ToString().ExceptionDispatchInfo and rethrow it on the main thread.ArgumentNullException.ThrowIfNull(customer.Address) and print the ParamName.throw vs one using a throw helper (BenchmarkDotNet DisassemblyDiagnoser).using System.Runtime.ExceptionServices;
public async Task PayAsync(PaymentRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request); // param name filled in
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.Amount);
try
{
await gateway.SendAsync(request, ct);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "Gateway call failed for {PaymentId}", request.Id);
throw; // keeps the Gateway.SendAsync frames
// throw ex; // CA2200: would reset the trace to this line
}
catch (TimeoutException ex)
{
throw new PaymentFailedException(request.Id, "Gateway timed out", ex); // context + inner
}
}
// Rethrow later or on another thread
public sealed class BackgroundJob
{
private ExceptionDispatchInfo? _failure;
public void Run()
{
try { DoWork(); }
catch (Exception ex) { _failure = ExceptionDispatchInfo.Capture(ex); }
}
public void ThrowIfFailed() => _failure?.Throw(); // original trace preserved
}
// Throw expression
public sealed class Customer(string name)
{
public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
}
throw ex; to rethrowWhat happens: catch (Exception ex) { Log(ex); throw ex; }.
Why it is wrong: resets the stack trace, hiding the real origin of the error.
Do instead: use throw; (and enable CA2200 as an error).
What happens: throw new OrderException("Failed to save order"); inside a catch.
Why it is wrong: the original exception and its stack trace are lost entirely.
Do instead: throw new OrderException("Failed to save order", ex);.
What happens: catch (Exception) { throw; } with nothing else.
Why it is wrong: adds noise and no value.
Do instead: remove the try/catch, or add real handling or context.
What happens: each layer logs then throw;.
Why it is wrong: duplicate log entries for one failure.
Do instead: log once at the boundary; use exception filters (when (Log(ex)) returning false) only when you need context from a specific layer.
throw ex across threadsWhat happens: storing an exception from a worker thread and later throw storedEx;.
Why it is wrong: the original thread's stack trace is lost.
Do instead: use ExceptionDispatchInfo.Capture(ex) and .Throw().
TargetInvocationException/AggregateException with throw inner;What happens: catch (TargetInvocationException tie) { throw tie.InnerException!; }.
Why it is wrong: resets the inner exception's stack trace.
Do instead: ExceptionDispatchInfo.Capture(tie.InnerException!).Throw();.
What happens: if (order == null) throw new ArgumentNullException("orderId");.
Why it is wrong: misleading messages; names drift during refactoring.
Do instead: ArgumentNullException.ThrowIfNull(order) (captures the name automatically) or nameof.
What happens: several inline throw new ... statements in a small, frequently called method.
Why it is wrong: can prevent inlining and bloat the hot path.
Do instead: use ThrowIf helpers or a [DoesNotReturn] throw-helper method.
What happens: long ternaries mixing values and throws. Why it is wrong: hard to read and debug. Do instead: use throw expressions for simple guards; use statements for complex logic.
What happens: production stack traces lack file and line numbers. Why it is wrong: even preserved traces are less useful. Do instead: publish portable or embedded PDBs and configure Application Insights to resolve them.
Seniors care about the diagnostic value of exceptions in production, because the stack trace is often the only evidence after an incident.
| Goal | Practice |
|---|---|
| Accurate traces | throw;, never throw ex;; CA2200 as error |
| Context without loss | wrap with inner exception at layer boundaries only |
| Cross-thread rethrow | ExceptionDispatchInfo |
| One log per failure | log at the boundary, not at every layer |
| Clean validation | guard helpers with CallerArgumentExpression |
They also know guard helpers keep the throw out of line ([DoesNotReturn], separate throw helper method), which helps the JIT inline the happy path, the same pattern the BCL uses internally.
InnerException links an exception to the one that caused it, so a high-level error ("order import failed") keeps the low-level cause ("connection timeout"). GetBaseException() returns the innermost cause, and ToString() prints the whole chain. AggregateException holds several inner exceptions in InnerExceptions; you get it from Task.Wait(), .Result, Parallel.For and Task.Exception. Flatten() merges nested aggregates and Handle(predicate) marks some as handled and rethrows the rest. await unwraps it and throws only the first inner exception, so after await Task.WhenAll(...) check task.Exception.InnerExceptions to see all failures. Reflection wraps errors in TargetInvocationException, and a failing static constructor gives TypeInitializationException, which makes the type unusable for the rest of the process.
Create a custom exception only when callers need to catch it specifically or need extra data that built-in exceptions do not carry. Otherwise use the standard ones: ArgumentException family for bad input, InvalidOperationException for wrong state, NotSupportedException, KeyNotFoundException, TimeoutException. A good custom exception derives from Exception (or a more specific built-in type), not ApplicationException, ends in Exception, provides the three standard constructors (none, message, message plus inner), carries domain properties such as OrderId, and is usually sealed. Do not add the old binary-serialisation constructor: that API is obsolete in modern .NET (SYSLIB0051). For expected business outcomes, a result type is often better than any exception.
The Call Stack shows every method that is currently active on the thread, newest at the top: who called whom to reach this line. I double-click a lower frame to jump to that caller, and Locals and Watch switch to that frame's variables, which is how I find where a bad value came from. Just My Code hides framework frames as [External Code]. For async code the real thread stack after an await starts in the thread pool, so Visual Studio stitches the logical async chain back together, and Parallel Stacks shows it across tasks. In Release builds, inlining can hide frames.
How to tell it (STAR)
**Situation:** At [company] we had intermittent payment failures and every log entry said the error came from the controller, which told us nothing. **Task:** I was on call and needed to find the real cause quickly. **Action:** I noticed every stack trace started inside a catch block, which is the signature of `throw ex;`. I found it in three layers, replaced it with `throw;`, removed the duplicate logging, and wrapped gateway timeouts in a domain exception that kept the inner exception. With accurate traces, the real cause showed up straight away: [a timeout in the card gateway client]. **Result:** We fixed the timeout the same day. I then made CA2200 a build error so `throw ex;` cannot come back, and our mean time to diagnose similar issues dropped from [days] to [minutes].
Last updated 30 Oct 2026.