C# Exception Handling Best Practices for Real Services
C# exception handling best practices for .NET services: throw specific types, catch only what you can fix, keep the stack trace, log at the root.
Wren CallowayC# exception handling best practices come down to four rules, and analyzer rules CA1031, CA2200 and CA2201 check three of them. Microsoft’s guidance, updated in October 2025, gives the reason: “a crashed app is more reliable and diagnosable than an app with undefined behavior.” The four rules cover throwing one specific type, catching only what the caller can act on, rethrowing without resetting the stack trace, and catching everything once at the root. As of October 2026, CA1031 ships disabled in .NET 10, so the catch rule stays a suggestion until a team turns it on.
What do C# exception handling best practices say about throwing and catching?
C# exception handling best practices are enforced by three analyzer rules: CA2201 for throwing, CA1031 for catching, and CA2200 for rethrowing.
| Practice | Rule | What it flags | Fix |
|---|---|---|---|
| Throw a specific type | CA2201 | Exception, ApplicationException, and nine runtime-reserved types including NullReferenceException |
Throw a predefined specific type, or a custom type deriving from Exception |
| Catch what you can act on | CA1031 | catch (Exception), catch (SystemException), bare catch |
Catch a specific type, or rethrow as the last statement in the block |
| Rethrow without resetting | CA2200 | throw e; |
Rethrow with a bare throw; |
Clean up in finally |
CA2219 | Raising an exception inside finally |
Keep cleanup code out of the throw path |
CA2201 names nine types the runtime reserves for itself, from AccessViolationException to StackOverflowException, and calls Exception and ApplicationException too general to throw (CA2201 page, dated November 2016). The catch rule has one legitimate use: adding context the caller can act on, keeping the original as the inner exception. Log at the layer where the operation started; the enterprise logging write-up covers moving .NET records through RabbitMQ into Graylog2.
Should C# code catch Exception at the application root?
C# code should catch Exception exactly once, at the application root, and a CA1031 warning is the analyzer asking for that one handler.
At that root a catch block has two jobs: record the failure, then let the request or the process die. Since .NET Framework 4 the CLR no longer delivers corrupted state exceptions such as Windows access violations to managed code (CA1031 page, dated November 2016), so a catch-all is not the safety net it looks like.
CA1031 is disabled by default in .NET 10 and is scoped with dotnet_code_quality.CA1031.disallowed_symbol_names in .editorconfig; Microsoft’s page says not to suppress the warning, because catching general types hides runtime problems from the caller. Order catch clauses from most derived to least derived (best practices page, October 2025). What the root writes should land somewhere durable; logging to a database is the cheapest version of that.
How does a C# method keep the stack trace when it rethrows?
C# keeps the original stack trace when a catch block rethrows with a bare throw;, and replaces it when the same block writes throw e;.
CA2200 flags the explicit form, and in .NET 10 it warns by default (CA2200 page, dated February 2023). Re-throwing outside the handler needs ExceptionDispatchInfo.Capture plus a later Throw(); the class applies from .NET Framework 4.5 onward and cannot be serialized across application domains (API page, dated July 2025).
A when filter handles some instances of a type and not others, and it is evaluated before the stack unwinds, so locals stay readable in a crash dump (C# language reference, dated January 2026). Filters arrived with C# 6 in 2015 and use a CLR feature that was there from the start, as Thomas Levesque noted in a June 2015 post.
When should C# code avoid exceptions entirely?
Checks and Try* methods beat thrown exceptions on routine paths in C#, and Int32.TryParse is the reference case: a Boolean plus an output value instead of an OverflowException.
“When a member throws an exception, its performance can be orders of magnitude slower,” the Framework Design Guidelines say in their 2008 reprint. The same page sets out two shapes for that case: test the condition before the operation, the way it checks ICollection<T>.IsReadOnly before Add, or give the member a Try prefix and a Boolean return while keeping the throwing member alongside it.
Microsoft’s exception page makes the same call for common conditions: check conn.State before conn.Close() rather than catching InvalidOperationException (page updated October 2025). That check removes the exception most of the time, though a race can still change the state between check and call. Returning null, or Nullable<T> where absence is meaningful, keeps a routine path free of throws.
Where should an ASP.NET Core app record unhandled C# exceptions?
ASP.NET Core’s exception handling middleware records unhandled exceptions once, at the top of the pipeline, and only before the response has started.
UseExceptionHandler catches and logs the exception, then re-executes the request on an alternate path, and rethrows the original exception if that path throws (Microsoft Learn, updated October 8, 2026). Once headers are sent, no handler can change the status code.
IExceptionHandler handles known exception types centrally: singleton instances, called in registration order until one returns true from TryHandleAsync, and never called unless UseExceptionHandler is also added. The interface was merged in April 2023 (dotnet/aspnetcore PR #47923) and ships in .NET 8 and later. From .NET 10 a handled exception suppresses the framework’s log and metric emission by default, where .NET 8 and .NET 9 always emitted it.
The response should carry problem details, RFC 7807, and no exception message. Where the record goes next is a separate choice: Log4Net appenders remain the usual way .NET code emits the event.
C# Exception Handling Best Practices FAQ
Should C# code catch Exception at all?
Yes, once, at the application root. Microsoft’s CA1031 page says general exception types should not be caught in library code, because catching them hides runtime problems from the caller. The single catch-all belongs where the request ends, recording the failure with the correlation id and then letting the process fail.
Why does throw e; lose the C# stack trace?
Because throw e; restarts the stack trace at the current method and drops the calls between the original throw and the rethrow, while a bare throw; leaves the original StackTrace intact. CA2200 flags the explicit form and, in .NET 10, warns about it by default. When the rethrow happens outside the handler, capture the exception with ExceptionDispatchInfo.Capture and rethrow with Throw(). The class works from .NET Framework 4.5 onward.
Should I write my own C# exception types?
Only when no predefined type fits. Microsoft’s guidance points at InvalidOperationException for an operation that is invalid for the object’s current state and ArgumentException for bad input. When you add your own, name it with the Exception suffix, derive it from Exception, and supply the three constructors: parameterless, message only, message plus inner exception. Deriving from ApplicationException is no shortcut, since CA2201 groups it with the types too general to throw.
How should an ASP.NET Core app record unhandled C# exceptions?
With UseExceptionHandler, which catches and logs unhandled exceptions and re-executes the request on an alternate path. Register IExceptionHandler implementations for the exception types you know; each is a singleton, they run in registration order, and the middleware stops at the first TryHandleAsync that returns true. Keep the client response as problem details under RFC 7807.