The throw statement raises an exception, and the way it is raised or rethrown decides what shows up in the stack trace during debugging. This lesson covers raising exceptions from your own code, the two forms of rethrow (throw; and throw ex;), the antipattern that destroys the original origin information, throw expressions in compact contexts, ExceptionDispatchInfo for preserving traces across asynchronous or cross-context boundaries, and the [DoesNotReturn] attribute that tells the compiler about helpers that never come back.
Raising an exception is one statement: throw new SomeException(...). The runtime allocates the exception object, captures a stack trace starting at the throw site, and then begins unwinding the call stack looking for a matching catch. Until something catches it, the exception keeps propagating up.
The most common reason to throw is validation. A method is asked to do something it cannot reasonably do, so it stops, signals the problem with the right exception type, and lets the caller decide how to react.
Two pieces of information land in the exception object. The message tells a human what went wrong, and the parameter name (passed via nameof(quantity)) tells tooling and callers which input caused the problem. The runtime appends the actual value because ArgumentOutOfRangeException accepts one in its constructor. These are constructor parameters traveling with the exception.
Picking the exception type matters more than picking the message. The Base Class Library already has the type for most validation problems, and using the matching one means callers can write a precise catch clause instead of catching Exception. A few rules of thumb:
| Situation | Throw |
|---|---|
| Argument was null | ArgumentNullException |
| Argument was outside an allowed range | ArgumentOutOfRangeException |
| Argument was the wrong shape for the operation | ArgumentException |
| Object is in a state that does not allow the call | InvalidOperationException |
| A feature is intentionally not implemented yet | NotImplementedException |
| The operation makes no sense for this type | NotSupportedException |
Modern .NET has helpers that bundle the check and the throw into one call. ArgumentNullException.ThrowIfNull arrived in .NET 6, and ArgumentOutOfRangeException.ThrowIfNegative, ThrowIfZero, ThrowIfGreaterThan, and friends arrived in .NET 8. They read cleaner than the manual check, and they pick up the parameter name automatically via CallerArgumentExpression.
The helper formats the message, names the parameter correctly, and produces a one-line guard at the top of the method. The same payment-related logic looks like this:
The throw site is where the exception's stack trace begins. The runtime walks the current call stack at the moment throw executes and records each frame in ex.StackTrace. That recording is the most important debugging artifact from an exception, so the rest of this lesson is about not destroying it.
Throwing an exception allocates the exception object and captures the current stack trace, which involves walking frames and copying method metadata. The fast path of a successful method is essentially free; the slow path of a thrown exception can cost microseconds, not nanoseconds. Use exceptions for exceptional cases, not for ordinary control flow like "the cart was empty."
throw;Besides handling the exception cleanly, a catch block can also let it keep going while doing some work along the way. Logging the failure, releasing a half-acquired resource, adding context to the exception, and then letting the original exception continue propagating is a common shape. The way to "let it keep going" is the bare throw; statement.
throw; is only legal inside a catch block. It rethrows the exception that is currently being handled, and crucially it preserves the original stack trace. The exception keeps its full history: where it was first thrown, what intermediate methods were on the stack at that moment, and now also the rethrow point.
The stack trace shows the original throw site (StockService.GetAvailableStock, line 12) at the top, and OrderService.ReserveStock appears twice: once on the call that invoked GetAvailableStock, and once on the rethrow line. The runtime records both because the exception passed through that method on the way up. The first line shows where the exception came from, which is the key information for debugging.
Use a bare throw; whenever the catch block exists to add side effects (logging, metrics, cleanup) but does not actually know how to recover from the exception.
A cleanup example follows. A cart-validation step opens some kind of resource, hits a problem, releases the resource in the catch, and then rethrows so the caller still learns the operation failed.
A using statement or finally block would do that cleanup more idiomatically, and the _finally Block_ lesson covered that path. The semantics of the bare throw;: the catch block did some recovery work, then handed the exception back unchanged.
throw; is the exception equivalent of returning void from a pass-through method. The method participated, but it did not change the answer.
throw ex; Is WrongThe bug looks tiny. A catch grabs the exception and the obvious-looking throw ex; rethrows it. The program compiles, the exception still propagates, the catch above still fires. Everything works, except the stack trace now lies about where the exception came from.
throw ex; is not a rethrow. It is a brand new throw operation that happens to use an existing exception object. The runtime sees a throw statement and resets the captured stack trace to begin at this line. The original throw site is gone, and the only frame at the top of the trace is the line in the catch block.
StockService.GetAvailableStock is no longer on the stack trace. The frame that actually threw the original exception has vanished. A bug report would conclude that OrderService.ReserveStock is where the trouble started, leading the reader to waste time on that method instead of the real culprit one frame up.
The difference between the two forms is hard to remember by reading the syntax, so a diagram of what ends up in the trace makes it concrete.
The exception object is identical in both branches. The only thing that changes is what ex.StackTrace says when the outer catch finally reads it. throw; keeps the full history, throw ex; keeps only the slice from this catch onward.
The rule is short: to rethrow, write throw;. To throw a new exception that wraps the old one, write throw new SomeException("...", ex); (the inner-exception form, covered properly in the _Inner Exceptions_ lesson). Never write throw ex; for the exception you just caught.
Some analyzers warn about this. The Roslyn rule CA2200 ("Rethrow to preserve stack details") flags throw ex; in any catch block where ex is the caught exception. Enable it in projects where it isn't already on.
The fix is to drop ex:
An important detail: the antipattern only applies to throwing the exception that was caught. Writing throw new MyException("context", ex) is a different operation. That throws a new exception, sets the original one as the InnerException, and starts a fresh stack trace beginning at the new throw site. That is a deliberate choice when adding domain context, and the original exception is preserved in the inner-exception chain. The _Inner Exceptions_ lesson covers that in depth.
Before C# 7, throw was strictly a statement. It could not appear anywhere an expression was expected, so guarding a value with ?? and falling through to a throw required a multi-line if-block. C# 7 changed that: throw can now appear as an expression in a handful of contexts, which lets short validation read in a single line.
The two most common spots are the right-hand side of ?? and the arms of a conditional expression (?:). Both have a natural "value or failure" shape that benefits from compact syntax.
The expression rawId ?? throw new ArgumentNullException(nameof(rawId)) reads as: take rawId if it is non-null, otherwise throw. It compiles down to the same IL as a regular if (rawId is null) throw ... followed by an assignment, so there is no performance difference; the form is purely for readability.
The conditional operator works the same way. One arm can return a value, the other can throw, and the type of the whole expression is the type of the value arm.
Switch expressions are a third place this shows up. A switch expression must produce a value, so a "no other case is valid" arm has to either return a sentinel or throw, and throwing is almost always correct.
The _ => throw ... arm gives the switch expression a way to be exhaustive even when the enum gets a new unhandled member. Without it, the compiler warns about a missing case and at runtime a different, less helpful exception would be raised.
Expression-bodied members make this even tighter. A getter that should never return null can express that intent in one line:
Throw expressions are not allowed everywhere. They are valid in ??, conditional ?:, switch expression arms, expression-bodied members, and lambda bodies that are themselves expressions. They are not valid as standalone expressions in the middle of arithmetic, for example, because a throw does not produce a value of any usable type. The compiler rejects int x = 1 + throw new Exception(); and similar constructs.
Sometimes neither a bare throw; nor a throw ex; is the correct option. The original exception can be wrapped in a new one that adds domain context, while preserving the original underneath for forensic value. That is the three-argument constructor pattern: throw new SomeException("context message", innerException);.
The outer catch sees an OrderProcessingException with the domain phrasing, and the original InvalidOperationException is reachable through ex.InnerException. The full chain is also accessible by walking InnerException repeatedly, which is what ex.ToString() does automatically.
This is mentioned here as a third option, distinct from the antipattern. The deep dive on chains, walking InnerException, and AggregateException is the job of the _Inner Exceptions_ lesson.
The bare throw; only works inside the actual catch block where the exception is live. As soon as control leaves the catch (return a value, await something, put the exception in a field for later), the language stops accepting throw;. To rethrow a stored exception object later, the obvious throw ex; resets the stack trace, as covered above.
System.Runtime.ExceptionServices.ExceptionDispatchInfo exists for this case. Capture the exception together with its current stack trace into a small object, carry it across boundaries, and later call .Throw() to rethrow it without resetting the trace.
The stack trace tells the whole story. The top of the trace is still StockService.GetAvailableStock (the original origin), the next frame is TryReserveStock (the capture site), and then a separator line says "End of stack trace from previous location" before the trace continues into Flush and Main. That separator is the runtime indicating the exception was captured at one point and resumed at another. Both halves of its history are preserved.
The same pattern is what await uses internally. When an awaited task faulted, the framework stored the exception with ExceptionDispatchInfo.Capture and rethrew it on the continuation thread via .Throw(). That is why an exception that originally happened on a thread pool worker still looks correct in the user's await stack trace, with the "End of stack trace from previous location" marker.
A typical user-facing place for this is a fan-out helper that collects exceptions from many tasks and chooses how to surface them.
The coordinator runs every validator, captures the first failure, and after the loop rethrows it through ExceptionDispatchInfo. The stack trace still points at the actual failing lambda, not at the line that called .Throw(). Storing the raw Exception and using throw ex; later would lose the original frame entirely.
A lifecycle diagram helps anchor the steps for a rethrown exception, whether the rethrow is a bare throw; or a deferred ExceptionDispatchInfo.Throw().
ExceptionDispatchInfo.Capture allocates a small object that retains the exception and its trace metadata. It is fine in normal flow, but capturing inside a hot loop to possibly rethrow later is wasteful. Capture only when the intent is to surface the exception elsewhere.
ExceptionDispatchInfo.Throw() always throws. Even though it is declared as returning void, the compiler does not know it never returns, which can cause the same kinds of definite-assignment warnings that a regular helper method would. The next section covers the fix.
[DoesNotReturn] AttributeRefactoring repeated throws into a small helper is common. Three or four methods all guard against the same condition with the same exception, the throw moves into a private static void helper, and the repetition disappears. The problem is that the C# compiler's flow analysis does not know the helper never returns, so it still expects the caller to assign return values or initialize variables on all paths.
The compiler does not realize ThrowItemNotInCart always throws. As far as it can see, the method has a void return type and could return cleanly, in which case the price.Value line on the next iteration would dereference a null value. To make the compiler stop guessing, decorate the helper with [DoesNotReturn] from System.Diagnostics.CodeAnalysis.
[DoesNotReturn] is a flow-analysis hint. It tells the compiler "any path that calls this method ends here." Three things change for the caller after applying it:
price.Value after the guard no longer warns about price being possibly null.The attribute does not change runtime behavior. It is purely metadata for the compiler and analyzers. A method tagged [DoesNotReturn] that accidentally returns at runtime will not crash the runtime, but it would mislead the static analysis, so the contract is that the method really must never return normally.
The same attribute belongs on ExceptionDispatchInfo.Throw()-based helpers and on any little utility that exists to centralize an exception throw.
Without the attribute, callers of Rethrow would get warnings about unreachable variables; with it, the compiler treats every call site as a control-flow dead end.
A matching attribute is [DoesNotReturnIf], also in System.Diagnostics.CodeAnalysis. It says "if this bool argument has this value, the method does not return." Use this for an assertion helper, which only throws on false.
The compiler now reasons: "if IsTrue is called with false, the method does not return; the only way control reaches the next line is if the condition was true." That is enough to silence the nullability warning on userId.Value.
The throw statement has a few limits beyond "must be inside a catch for throw;." A handful of misuses produce compile errors or runtime exceptions.
The first is throwing null. From C# 8 onward, with nullable annotations on or off, the compiler rejects an obvious null throw at the source.
The compiler emits warning CS8597 ("Thrown value may be null") in a nullable-aware context. Even without that warning, the runtime treats a null throw specially: it throws NullReferenceException instead of trying to use the null as an exception object. The fix is to make sure the value is non-null before throwing.
The second limit is throw; outside a catch. The bare throw; form is only legal inside a catch block, because it needs a "current exception" to rethrow. The compiler rejects it elsewhere.
The fix is either to throw a new exception (throw new SomeException(...)) or to move the statement inside a catch where there is something to rethrow.
The third constraint is that the type passed to throw must derive from System.Exception. C# does not allow throwing arbitrary objects, integers, or strings. The compiler enforces this at the call site.
There is a historical reason to mention this. The CLR itself does support throwing non-Exception types because some other .NET languages emit them, and starting in .NET 2.0 any non-Exception throw from elsewhere is wrapped in a RuntimeWrappedException when it reaches C# code. This rarely surfaces. C# code throws only Exception-derived types.
Constructors throwing is fine, and is correct when an object cannot be constructed in a valid state. Property getters and setters throwing is also legal, but should be reserved for genuine error conditions, not for ordinary input validation, because callers rarely expect a simple property read to throw. The same goes for setters: throw on an out-of-range value, but do not throw on the cosmetic shape of the value if it can be normalized.
Throwing from the constructor is correct here: the object cannot be valid, so it must not exist. The alternative (allowing a half-formed instance and reporting "isValid" somewhere) leaks invalid state into the rest of the program.
9 quizzes