I’ve written about .NET exceptions twice before: once on exception handling best practices in general, and once on exception handling in asynchronous code. Both are long, and sometimes you just want the rule without the essay. This post is the short version: the practices from those articles plus a few newer ones, grouped by situation, each with a sentence or two on why. .NET has changed a lot since 2018, so where the older advice no longer holds, I’ve said so.
Catching and rethrowing
- Catch only the exceptions you expect and can do something useful with. A broad
catch (Exception)belongs at the top of the application, in the exception-handling middleware, a background worker’s loop or a message handler. It doesn’t belong in every method. - Never swallow an exception. An empty catch block hides the one clue you’ll need later. If ignoring an exception really is correct, add a comment explaining why.
- Rethrow with
throw;.throw ex;resets the stack trace to the current line, so the original failure point is lost. - Handle exceptions at the level that can make a decision. A low-level method might throw an
IOException, but only the caller knows whether to retry, ask the user or give up. Where a lower-level exception would leak implementation details, translate it into one that makes sense at your layer, and pass the original as the inner exception. - Use exception filters (
catch ... when) to catch only the cases you care about. The filter runs before the stack unwinds, and if it returns false, the exception continues untouched. - Log an exception once, where you handle it. Catching, logging and rethrowing at every layer turns one failure into five log entries.
logger.LogError(ex, ...)already records the message, the stack trace and every inner exception, so there’s no need to log those properties separately. I go into this in more detail in designing methods for logging and exception handling.
try
{
return await catalogClient.GetProductAsync(productId, ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
throw new ProductNotFoundException(productId, ex);
}
Throwing: validation and the right exception type
Validate preconditions at the top of a method and fail immediately, before bad input has a chance to do any damage. Since .NET 6, the argument exceptions have static helpers that make guard clauses one line each:
public Invoice CreateInvoice(Order order, int dueInDays, string currency)
{
ArgumentNullException.ThrowIfNull(order);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(dueInDays);
ArgumentException.ThrowIfNullOrWhiteSpace(currency);
if (order.Status != OrderStatus.Confirmed)
{
throw new InvalidOperationException(
$"Order {order.Id} must be confirmed before invoicing; it is {order.Status}.");
}
// ...
}
- Throw
ArgumentNullException,ArgumentOutOfRangeExceptionorArgumentExceptionfor bad arguments, andInvalidOperationExceptionwhen the arguments are fine but the object is in the wrong state for the call. - Write messages for the developer who will read them in a log. Say what was expected, what actually happened, and include the identifiers needed to find the record.
- Use
Debug.Assertfor conditions that should be impossible if your own code is correct. Asserts only run in Debug builds, so they don’t replace validation of input that comes from outside. - Property getters shouldn’t throw. Callers, debuggers and serializers all read properties expecting them to be safe. A setter may throw
ArgumentExceptionfor an invalid value.
Cleaning up
Any resource that must be released (a connection, a file handle, a lock) belongs in a using statement or a finally block, so it’s released whether the code succeeds or throws. Since C# 8, a using declaration (using var stream = File.OpenRead(path);) disposes the resource at the end of the enclosing scope without adding another level of nesting. For types that implement IAsyncDisposable, use await using. Use a plain finally for cleanup that isn’t disposal, such as resetting a flag or releasing a SemaphoreSlim.
When not to throw
- Don’t use exceptions for normal control flow. If a failure is expected and common, such as invalid user input or a key that might not exist, check for it. The
Trypattern (int.TryParse,Dictionary.TryGetValue) returns abooland gives the result through anoutparameter. - Don’t catch an exception and return
nullinstead. The caller then gets aNullReferenceExceptionthree methods later, with no hint of what originally went wrong. If “not found” is a normal outcome, make that explicit with theTrypattern, a nullable return type that’s documented as such, or a result type. - Result types make a failure part of the method’s signature, so the caller can’t ignore it. C# has no built-in discriminated unions yet, but libraries such as ErrorOr, FluentResults and OneOf fill the gap. A reasonable split is result types for expected failures, like validation errors or “not found”, and exceptions for everything unexpected.
- Throwing is expensive compared with returning a value. .NET 9 made exception handling roughly two to four times faster, but a throw in a hot loop still costs far more than an
if. Keep exceptions out of code that runs thousands of times per second.
Custom exceptions
Create a custom exception when callers need to catch that specific failure, or when it carries data they need. A ProductNotFoundException with a ProductId property tells the caller more than a generic NotFoundException:
public sealed class ProductNotFoundException : Exception
{
public ProductNotFoundException(int productId, Exception? innerException = null)
: base($"Product {productId} was not found.", innerException)
{
ProductId = productId;
}
public int ProductId { get; }
}
Two parts of the old advice are out of date. Derive from Exception, or from a more specific built-in type, and not from ApplicationException. Microsoft stopped recommending ApplicationException long ago because it adds nothing. And on .NET Core and .NET 5 or later, you no longer need [Serializable] or the serialization constructor. Binary serialization is obsolete, and .NET 8 marks the Exception(SerializationInfo, StreamingContext) constructor as obsolete (SYSLIB0051). You only need them if you still pass exceptions across .NET Framework remoting or AppDomain boundaries.
Exception handling in asynchronous code
awaitrethrows the original exception, not anAggregateException, so you can catch it exactly as you would in synchronous code. You only seeAggregateExceptionwhen you block with.Resultor.Wait(), or when you readTask.Exceptiondirectly.- Don’t block on async code.
.Resultand.Wait()wrap the exception, waste a thread, and can deadlock in UI apps and classic ASP.NET, which have a synchronization context. ASP.NET Core has no synchronization context, so it won’t deadlock there, but under load you’ll run into thread pool starvation instead. In library code,ConfigureAwait(false)prevents the deadlock for callers who block anyway. - When you await
Task.WhenAll, only the first exception is rethrown. To see all of them, keep a reference to the combined task:
Task all = Task.WhenAll(SyncInventoryAsync(ct), SyncPricesAsync(ct));
try
{
await all;
}
catch
{
if (all.Exception is { } aggregate)
{
foreach (Exception ex in aggregate.InnerExceptions)
{
logger.LogError(ex, "Sync step failed");
}
}
throw;
}
- Avoid
async voidexcept for event handlers. An exception thrown from anasync voidmethod can’t be caught by its caller. It goes straight to the synchronization context or the thread pool, and that usually crashes the process. Every async event handler should wrap its body in a try-catch:
private async void SaveButton_Click(object sender, EventArgs e)
{
try
{
await SaveAsync();
}
catch (Exception ex)
{
logger.LogError(ex, "Saving failed");
ShowError("Your changes couldn't be saved. Please try again.");
}
}
- Exceptions in fire-and-forget tasks disappear silently. Since .NET 4.5, an unobserved task exception no longer crashes the process. It’s raised on
TaskScheduler.UnobservedTaskExceptionwhen the task is garbage collected, and nothing else reports it. Either await the task, or catch and log inside it. OperationCanceledException, along withTaskCanceledException, which derives from it, usually means the caller cancelled. That isn’t an error, so don’t log it as one. One trap: whenHttpClienttimes out, it also throwsTaskCanceledException, with aTimeoutExceptionas the inner exception. Checkct.IsCancellationRequestedto tell the two cases apart.ContinueWithwithTaskContinuationOptions.OnlyOnFaultedis how this was done beforeawait. It still works, but it runs onTaskScheduler.Currentby default, which can surprise you. In new code, a try-catch aroundawaitdoes the same job more clearly.
Application-level handling
- Centralize it. In ASP.NET Core, register
AddProblemDetails()and anIExceptionHandler(.NET 8 and later), then callUseExceptionHandler(). Every unhandled exception is then logged once and returned as a consistent Problem Details response, and controllers don’t need their own try-catch blocks. Because the handler is registered with dependency injection, it’s also the one place to map exception types to status codes. - Background services. Since .NET 6, an exception that escapes
BackgroundService.ExecuteAsyncis logged and then stops the whole host. Put a try-catch around each iteration of the worker loop, so one bad message doesn’t take down the service, but letOperationCanceledExceptionthrough when the app is shutting down. - Last-chance logging.
AppDomain.CurrentDomain.UnhandledExceptionlets you log an exception that nothing else caught, but it can’t prevent the process from ending. Restarting the process is the job of whatever runs it: Kubernetes liveness probes, a Windows service’s recovery settings, or Azure App Service’s health checks. Environment.FailFastis for situations where the process state is so corrupted that continuing would do more harm than good. It ends the process immediately, without runningfinallyblocks or finalizers, and writes a message to the Windows event log. It’s the wrong tool for an ordinary failed precondition. Throw an exception for those.- Retries and circuit breakers. Transient failures such as network blips, timeouts, 429 or 503 responses and SQL deadlocks are worth retrying with exponential backoff and jitter. Use Polly v8, or
Microsoft.Extensions.Http.ResilienceforHttpClient. A circuit breaker stops calling a dependency that keeps failing, which prevents one outage from spreading into others. Retry only operations that are safe to repeat. - Error codes in APIs. Exceptions don’t cross process boundaries, but error codes do. Give each kind of failure a stable code, either as the Problem Details
typeURI or as anerrorCodeextension field. Clients can then branch on the code instead of parsing message text, which breaks the first time someone rewords a message. - Localization. Keep exception messages in one language, because developers and log searches depend on them. Translate at the edge instead: map the error code to a localized message with
IStringLocalizerand resource files when you show it to a user. - What users see. Show users a short, calm message that tells them what they can do next. Leave out stack traces, SQL errors and internal type names. The trace ID in the response is enough for support to find the details in your logs.
Specific kinds of failure
- Database:
SqlException.Numbertells you which error occurred. 1205 means your transaction was chosen as a deadlock victim, and -2 is a timeout. Both are worth retrying. Constraint violations are not. EF Core’sEnableRetryOnFailure()retries SQL Server’s transient errors for you.DbUpdateConcurrencyExceptionmeans someone else changed the row first, so reload the data or tell the user. Don’t retry blindly. - Network: Since .NET 5,
HttpRequestException.StatusCodegives you the response status code. Treat timeouts, connection failures and 5xx responses as possibly transient. A 4xx means the request itself was wrong, and sending it again won’t help. - I/O: A
FileNotFoundExceptionwon’t fix itself, but anIOExceptioncaused by another process holding the file often clears up after a short wait. Handle the two differently. - Concurrency: Most race conditions don’t throw anything. They just corrupt state, so the fix is design (immutable data, locks, concurrent collections), not catch blocks.
Parallel.ForEachand PLINQ collect failures in anAggregateException.Flatten()andHandle()help you work through it. - Security: An exception message can reveal file paths, SQL or user data. Make sure none of that reaches the client, and be careful what you log as well.
- Third-party libraries: Read the library’s documentation for the exceptions it throws, and translate them at the boundary of your code. That way the rest of your application doesn’t depend on, say, a payment provider’s exception types.
Testing, documenting and monitoring
Test the failure paths as deliberately as the success paths. xUnit’s Assert.Throws and Assert.ThrowsAsync check both the exception type and its contents. To make a dependency fail, configure a mock (Moq or NSubstitute) to throw, so you can see how your code reacts to a database or HTTP error:
[Fact]
public void CreateInvoice_rejects_unconfirmed_orders()
{
var order = new Order { Id = 7, Status = OrderStatus.Draft };
var ex = Assert.Throws<InvalidOperationException>(
() => service.CreateInvoice(order, 30, "EUR"));
Assert.Contains("7", ex.Message);
}
The exceptions a public method throws are part of its contract. Document them with /// <exception cref="ProductNotFoundException"> so they appear in IntelliSense. Changing which exception a method throws is a breaking change for anyone catching it. If a new version needs a more specific exception, derive it from the one callers already catch, so their existing catch blocks still work. Write your exception-handling conventions down with the rest of your coding conventions, so the whole team handles failures the same way.
Finally, watch exceptions in production. Application Insights, or any OpenTelemetry back end, records them with their type and stack trace. Alert on the exception rate, and on exception types that first appear after a deployment. Those are the first signs that a release broke something.
Further reading
Best practices for exceptions | Microsoft Learn
Exception throwing guidelines | Framework Design Guidelines
Async/Await: Best Practices in Asynchronous Programming by Stephen Cleary
.NET Exceptions Best Practices: When to Catch or Throw (hamidmosalla.com)
Exception Handling in Asynchronous Code (hamidmosalla.com)
.NET Exceptions Best Practices | fullstackhero on Medium

One thought on “C# Exception Handling Best Practices: A Quick Reference”