Last Updated: May 17, 2026
Exception handling syntax is easy. Knowing when to throw, what to catch, where to wrap, and what to log is the part that separates a healthy codebase from one that swallows bugs and leaks stack traces into log files nobody reads. This chapter pulls together the rules every C# team eventually converges on, with opinions baked in.
An empty catch block is almost always a bug. It says "if anything goes wrong here, pretend it didn't," which means the next time something does go wrong, you have nothing to work with: no log line, no stack trace, no signal that the operation failed. The order silently fails to ship, the cart silently fails to apply a discount, and three weeks later someone files a support ticket you have no way to diagnose.
That code looks defensive. It is the opposite. The customer sees a success page. The inventory was never updated. The card was never charged. The email never went out. And there is no trace of any of it.
The fix has three options, and which one applies depends on what you actually want to happen on failure.
Option 1: Don't catch. If the caller is better placed to decide what to do, let the exception propagate. This is the right answer most of the time.
Option 2: Catch, log, and rethrow. Use this only at a boundary where you need to add context the caller doesn't have.
Option 3: Catch a specific type and translate it. Wrap the low-level failure in a domain exception so callers can handle it without knowing the implementation detail. We cover this in Rule 7.
What you should never do is the empty catch. There is a vanishingly small list of cases where it's defensible (a fire-and-forget background notification where the only consequence of failure is "the notification didn't go out, and you don't care"), and even then, you should log the exception. If you find yourself writing catch { }, stop and ask: what did the user expect to happen here, and what actually happened? The answer is almost never "nothing."
Cost: An empty catch costs nothing at runtime. It costs everything when you're trying to debug production. Visibility is worth more than the few characters you'd save.
ExceptionCatching Exception at random points in your code is the close cousin of the empty catch. It works, but it catches too much: a NullReferenceException from a bug in your own code, an OutOfMemoryException from the process running out of memory, an OperationCanceledException from a user clicking cancel, and the actual HttpRequestException you meant to handle. Treating them all the same way is rarely what you want.
That catch (Exception) returns "Description unavailable" whether the call failed because the network is down (a transient error, retryable) or because the code dereferenced a null _client (a bug, not retryable). The user sees a friendly fallback either way, and the bug never surfaces.
The narrower catch tells the truth:
Now an HttpRequestException from a real network failure produces the fallback. A NullReferenceException from a bug propagates up, and the developer sees the stack trace they need to fix it. The point here is the rule: name the exception types you actually expect, and let everything else fly.
There are exactly two places where catching base Exception is the right move:
Main, the top-level handler in a desktop app, the top-level error handler in a web framework. Something has to be there to log the unhandled exception and produce a clean shutdown message instead of a process crash dump.Exception around each iteration so a poison message doesn't take down the whole worker. The exception still gets logged, and the message gets routed to a dead-letter queue or marked as failed.The outer catch is broad because the worker has to keep running. It logs, it routes the bad message somewhere safe, and it moves on. The cancellation case is special-cased with a when filter so a real shutdown still propagates.
Anywhere else, prefer specific types. If the method you're calling can throw three different exceptions and you need to handle them differently, write three catch blocks. If you only care about one of them, catch only that one and let the rest go.
Cost: The try-catch itself is essentially free on the happy path. The cost is on the throw, which we cover in Rule 5. The catch block doesn't penalize the no-exception path.
When invalid input crosses into your code, throw immediately. The further the invalid value travels before you reject it, the more confusing the eventual failure becomes. A null customer parameter that triggers a NullReferenceException six method calls deep is much harder to debug than the same parameter rejected at the top of the entry method with a precise error.
The classic guard pattern was a manual check at the top of every public method:
That works, and you'll see it in many codebases. Since .NET 6, the BCL ships official helpers that do the same thing in one line. These are the modern way to write guards, and most teams have moved to them.
A few details worth knowing:
| Helper | Throws when | Available since |
|---|---|---|
ArgumentNullException.ThrowIfNull(value) | value is null | .NET 6 |
ArgumentException.ThrowIfNullOrEmpty(value) | value is null or "" | .NET 7 |
ArgumentException.ThrowIfNullOrWhiteSpace(value) | value is null, "", or whitespace | .NET 8 |
ArgumentOutOfRangeException.ThrowIfNegative(value) | value is negative | .NET 8 |
ArgumentOutOfRangeException.ThrowIfGreaterThan(value, max) | value > max | .NET 8 |
ObjectDisposedException.ThrowIf(condition, instance) | condition is true | .NET 7 |
The helpers use [CallerArgumentExpression] to capture the parameter name automatically, so the resulting exception message names the offending argument without you having to spell it out with nameof(). The error message you see in the log will be "Value cannot be null. (Parameter 'customer')", which is exactly what you want.
Constructors get the same treatment. A Cart constructed with a null owner is a zombie object waiting to break something downstream. Reject it in the constructor:
The point isn't that null is uniquely evil. The point is that invalid state should never be allowed to exist. If you accept a malformed Cart and rely on every downstream method to "be careful," sooner or later one of them won't be, and you'll get a NullReferenceException in a place that has nothing to do with the original mistake.
Opinion: most teams favor the ThrowIf* helpers over hand-rolled checks now. They're shorter, they get the parameter name right automatically, and they're easier to skim past as a reader. If your codebase still uses if (x == null) throw new ArgumentNullException(nameof(x)) everywhere, migrating to the helpers is a one-time cleanup that pays for itself in readability.
There's a related .NET 6+ helper, [StackTraceHidden], that's worth knowing if you write your own guard helpers. Attaching the attribute to a method tells the runtime to omit that method's frame from the stack trace when an exception bubbles out of it. That keeps your stack traces pointing at the real caller instead of at your helper.
If Guard.NotEmpty(productName) throws, the stack trace shows the caller of Guard.NotEmpty, not Guard.NotEmpty itself. For trivial wrappers that aren't where the bug is, that's exactly the right behavior.
Exceptions are for the exceptional. The expected outcomes of an operation belong in the return value, not in the catch block. Parsing a coupon code the user typed is not exceptional. Looking up a product that may or may not exist is not exceptional. A user clicking "remove" on an item that's already been removed is not exceptional. Code those as normal control flow, and reserve exceptions for genuine failures.
The classic antipattern:
Every malformed coupon entered by a user goes through a thrown-and-caught exception. On a busy site that's many thousands of throws per minute, each one allocating a stack trace and burning CPU. The same code with TryParse does zero allocation on the failure path:
Try* methods are the .NET convention for "may fail predictably." int.TryParse, decimal.TryParse, DateTime.TryParse, Dictionary<,>.TryGetValue, Queue<>.TryDequeue, ConcurrentDictionary<,>.TryAdd, and so on. They return a bool and use an out parameter to deliver the value on success. The expected-failure path is just if (!TryParse) ..., no exception in sight.
When the BCL doesn't give you a Try* method but the operation has an expected failure mode, write your own:
Two methods, two contracts. TryGetProduct returns false when the product isn't there. GetProduct throws. Callers pick the one that matches their situation: a search feature that might return zero results uses TryGetProduct; an order-creation step that requires the product uses GetProduct and lets the absence propagate as an error.
When the expected outcomes are richer than "found or not found," a Result-style discriminated value works well. C# doesn't have a built-in Result<T>, but it's a small type to write:
The caller can pattern match or check the flag, and the failure modes are all part of the normal return contract. No exception types to design, no try-catch around every call, and the validation logic reads top-to-bottom.
Opinion: most C# teams stop short of building full Result<T> infrastructure with monadic chaining like F# or Rust have. The simple version above is enough for the cases where an exception would be overkill but a bool is too thin to carry the failure reason. For really small failure spaces, the Try* pattern is still the idiomatic move.
The rough test for "should this be an exception?" is: would I be willing to put this in a catch (Exception) at the top of the app and log it as a bug? If yes, throw. If no (because it's a normal user-facing condition like "coupon doesn't exist"), don't.
The throw itself is expensive. The try-catch is not. That asymmetry is the single most important performance fact about exceptions in .NET, and most "exceptions are slow" advice gets it wrong.
The cost of a thrown exception comes from capturing the stack trace. The runtime walks the stack, records the frames, allocates strings for type and method names, and produces a snapshot the caller can inspect. That's not free, and on a deep call stack it's not even close to free.
The happy path traverses three small nodes and stops. The throw path adds two heavy nodes (stack trace capture and unwinding) plus the catch handler. Each of those costs nanoseconds on a shallow stack and microseconds on a deep one.
A rough rule of thumb: throwing and catching an exception is somewhere around a thousand times more expensive than a normal method return. The exact factor varies by stack depth, runtime version, and whether [StackTraceHidden] is involved, but a thousandfold is a reasonable mental model and matches benchmark results you'll see floating around. A code path that returns in 50 nanoseconds when it succeeds takes 50 microseconds when it throws.
That difference doesn't matter for the genuine failure case. A network call failing, a deadlock breaking, a database constraint rejecting a write: these are rare enough that the cost is invisible. It does matter when you start using exceptions as control flow, and especially when you do it in a hot loop.
Output (illustrative, varies by machine):
Half the inputs throw, and the runtime spends most of the time capturing stack traces. The TryParse version walks the same data, branches on the boolean, and finishes essentially instantly. This is the "never throw in a hot loop" rule made concrete.
Cost: A thrown exception allocates the exception object, captures the full stack trace, and walks frames to find the matching catch. On a deep call stack it can easily reach hundreds of microseconds. Don't throw inside an inner loop, and don't use throw as a way to break out of nested code.
The good news is that just having a try-catch doesn't cost anything significant when nothing throws. The JIT emits a small amount of metadata so the runtime knows where the protected region is, but the generated code on the happy path is the same as code without the try. Wrap a method body in try-catch and benchmark a hundred million calls against the same method without the try, and the difference is in the noise. The try-catch itself isn't the part you should worry about. The throw is.
The practical guidance:
try-catch freely where it makes the code clearer.throw in inner loops and on the per-row path of data-processing code.Try* patterns or Result types.Some exception types are reserved for the runtime, and others are too generic to communicate anything useful. Throwing them from your own code is a code smell that makes the failure harder to diagnose and harder to catch precisely.
The list of types you should not throw yourself:
| Type | Why you shouldn't throw it |
|---|---|
Exception | Too generic. Callers have nothing to catch specifically, so they either catch everything or nothing. |
SystemException | Reserved by the BCL for system-level errors. Throwing it from app code is semantically wrong. |
ApplicationException | Originally intended as the base class for app exceptions, but Microsoft now recommends against it. Inherit from Exception instead. |
NullReferenceException | The runtime throws this for null dereferences. Throwing it manually is misleading. Use ArgumentNullException for null parameters. |
IndexOutOfRangeException | Same story as NullReferenceException. Throw ArgumentOutOfRangeException for invalid indices passed to your API. |
StackOverflowException | Reserved. Cannot be caught in normal handlers since .NET 2.0. |
OutOfMemoryException | Reserved. The runtime throws it. |
ExecutionEngineException | Reserved. The runtime throws it. |
AccessViolationException | Reserved. The runtime throws it. |
Three of these come up often enough to flag specifically.
Don't throw bare `Exception`. The whole point of the type hierarchy is to let callers catch a narrow class of failure and ignore the rest. Throwing Exception defeats that.
The caller has to write catch (Exception) to handle this, which catches everything else too. Use a specific built-in or write a custom type:
Don't throw `NullReferenceException`. The runtime owns this one. When a caller sees NullReferenceException in a stack trace, they assume there's a bug in the code that did the dereferencing, not that someone passed a null parameter. If you want to signal "this argument was null," ArgumentNullException is the right type.
Don't inherit from `ApplicationException` for your custom exceptions. It used to be the recommendation, but the BCL itself doesn't follow it consistently, and Microsoft now explicitly recommends inheriting from Exception directly.
The same idea applies in reverse: don't catch types you can't meaningfully handle. Catching OutOfMemoryException to "recover" is almost always wishful thinking, because by the time you've caught it the process is already in a bad state. Catching StackOverflowException doesn't work at all since .NET 2.0. Don't write defensive code against failures the runtime owns; let them propagate and crash the process cleanly.
When a low-level exception bubbles up from an infrastructure call, it usually carries the wrong vocabulary for the caller above. A SqlException with "Cannot insert duplicate key" tells the database access layer what happened, but the order service that called it doesn't speak SQL. The caller cares whether the order was placed, not which constraint fired.
Wrap the low-level exception in a domain-specific type at the seam between layers. Pass the original exception as the inner exception so the original stack trace and message are still there for diagnostics. The _Inner Exceptions_ lesson is the deep dive on this pattern. Here's the rule in shorthand.
Each layer translates the failure into terms its caller can act on. The chain of inner exceptions preserves the diagnostic detail. The top layer can catch OrderPlacementFailed without ever having seen SqlException, and a developer reading the log can still drill down to the original.
The repository catches the SQL-specific failure, translates it into a domain exception that the order service understands, and preserves the original. The order service can react to DuplicateOrderException without knowing anything about SQL Server error codes.
Two things to avoid when wrapping:
throw new MyException("...") without passing ex to the constructor loses the original stack trace. Always pass it.The boundaries worth wrapping at:
Inside a single layer, let exceptions propagate as-is unless you're adding context that genuinely helps the caller.
The temptation to "log and rethrow" at every layer feels defensive but produces noise. The same failure shows up four times in the log file, each entry from a different layer, each with a slightly different stack trace. When something goes wrong in production, the engineer on call has to read four stack traces to figure out which one is the original. Worse, log-and-rethrow at every layer makes it impossible to tell from a single log line whether a given failure was actually handled or just observed and re-raised.
The cleaner pattern is: log once, at the layer that is responsible for the user-visible outcome. That's usually the top-level handler in a web app, the message-loop handler in a worker, or the entry point in a console tool. Inner layers throw or wrap. They don't log.
The controller is the boundary between the request and everything inside. It's the layer that decides what response the user sees, and it's the layer that owns the log line. OrderService.PlaceOrder does no logging at all. It throws specific exceptions, and the controller decides which ones become warnings, which become information, and which become unhandled errors with a 500 response.
The rule has a few useful corollaries:
"Order {OrderId} failed") instead of $"Order {orderId} failed". The former preserves orderId as a structured field for search and aggregation; the latter loses it in a string.Include enough context in the message to make the log entry searchable. The order ID, the customer ID (or hashed ID), the operation name, the input parameters that drove the failure. Never include personally identifiable information (PII) like full names, email addresses, addresses, or payment data, and never include secrets like API keys or tokens.
Most logging frameworks will redact known sensitive fields if you tell them which ones, but the safer default is: log the IDs, not the values. An audit trail can correlate the IDs back to the customer record without ever exposing the customer's data in a log line.
Cost: Each call to _logger.LogError(...) formats the message and writes to one or more sinks. On a hot path that's not free, but it's the price of observability. If your hot path is fast enough that logging dominates, that's a sign the operation is fundamentally cheap and shouldn't be running through a logger in the first place.
Three corners of the language have stricter rules than the general best-practices list above.
Dispose is expected to be safe to call any number of times and to never throw. It's frequently called from finally blocks that are themselves cleaning up after a previous exception, and if Dispose throws on top of an existing exception, the original is lost. Catch and log inside Dispose instead of letting an exception escape.
Finalizers are even stricter. Unhandled exceptions from a finalizer terminate the process since .NET 2.0. Most code doesn't need a finalizer at all; the IDisposable pattern covers the cleanup cases.
Event handlers that throw cause the remaining subscribers not to run, and in UI frameworks they can take down the dispatcher. Either each handler catches its own exceptions, or the raiser invokes handlers one at a time inside a try-catch. The _try-catch Block_ lesson covers the mechanics.
The common thread: exception handling in these places isn't optional. Throwing from Dispose, a finalizer, or an unguarded event handler creates failures that are much worse than the original problem.
The rules above as a single reference table. Pin this somewhere visible.
| Rule | Do | Don't | Why |
|---|---|---|---|
| Don't swallow | Log, rethrow, or wrap | Empty catch { } blocks | Silent failure makes production bugs impossible to diagnose. |
| Catch specific | Catch the type you can actually handle | Catch base Exception everywhere | Broad catches hide bugs you don't mean to handle. |
| Fail fast | Validate at the boundary with ArgumentNullException.ThrowIfNull | Let invalid input travel deep into the call stack | The further bad input travels, the harder the eventual failure is to read. |
| No exceptions for control flow | TryParse, TryGetValue, Result-style return values | try { Parse(...); } catch (FormatException) { ... } in a loop | Throwing is ~1000x slower than returning. Expected failures aren't exceptional. |
| Mind the cost | Throw freely at the boundary, never in a hot loop | Use throw as a way to break out of nested code | Stack trace capture allocates and walks frames. The throw is the cost, not the try. |
| Don't throw these types | Throw ArgumentNullException, InvalidOperationException, or a custom type | Throw Exception, NullReferenceException, ApplicationException | Reserved or too generic. Callers can't catch them precisely. |
| Wrap at the boundary | Translate low-level exceptions into domain types with the inner exception preserved | Let SqlException leak into a web controller | Each layer should speak the right vocabulary, while keeping the chain for diagnostics. |
| Log once, at the top | Log at the boundary that owns the user-visible outcome | Log-and-rethrow at every layer | Duplicate log lines drown the signal in noise. |
| Don't throw from Dispose, finalizers, event handlers | Catch, log, and continue | Let exceptions escape these special methods | The runtime or framework can't recover, and the original cause gets lost. |
The mechanics of try, catch, finally, throw, custom exceptions, and inner exceptions are mostly straightforward once you've used them a few times. What turns them into a workable strategy is the discipline around when to use which. Catch what you can handle, throw at the boundary, validate fast, don't throw in loops, wrap as you cross layers, log once at the top, and keep the special methods (Dispose, finalizers, event handlers) on a tight leash.
If a single mental model helps, try this one: exceptions are for things that broke a contract. A null parameter where the contract says non-null broke the contract. A network call that failed when the contract said it should succeed broke the contract. A user typing a malformed coupon code did not break any contract, because users typing garbage is a normal part of the system. The first two get exceptions. The last gets a bool and a friendly message.
ArgumentNullException.ThrowIfNull, ArgumentException.ThrowIfNullOrWhiteSpace, and the other ThrowIf* helpers added in .NET 6 through .NET 8. They capture the parameter name automatically and produce cleaner stack traces.TryParse, TryGetValue, custom Try* methods, and Result-style return values cover the cases where failure is an expected outcome instead of a contract violation.Exception, SystemException, ApplicationException, NullReferenceException, or IndexOutOfRangeException from your own code. They're either reserved for the runtime or too generic to catch precisely.