Articles › C#

Exception Handling in C#: Best Practices and Common Mistakes

Handle exceptions in C# like a professional: try/catch/finally, exception filters, rethrowing correctly, custom exceptions, using and IDisposable, async exceptions and when not to throw.

Exceptions are C#'s mechanism for reporting failures that a method can't handle itself. Used well, they make errors impossible to ignore and keep the "happy path" clean. Used badly, they hide bugs, destroy stack traces and slow applications down. This guide covers the rules that experienced .NET developers follow.

The basics: try, catch, finally

try
{
    var json = await File.ReadAllTextAsync(path);
    config = JsonSerializer.Deserialize<AppConfig>(json)!;
}
catch (FileNotFoundException)
{
    config = AppConfig.Default;                 // expected and recoverable
}
catch (JsonException ex)
{
    throw new InvalidOperationException($"Config file '{path}' is invalid.", ex);
}
finally
{
    logger.LogDebug("Config loading finished");  // always runs
}
  • catch blocks are evaluated top to bottom — put specific exception types before general ones.
  • finally runs whether the try block succeeds, throws or returns — ideal for cleanup.

Rule 1: Catch only what you can handle

A catch block should do something meaningful: recover, retry, translate the error, or add context. If you can't, let the exception propagate to a higher level (often a global handler).

// ❌ Swallowing: the bug disappears, the user sees wrong data
try { await SaveAsync(order); }
catch { }

// ❌ Catching Exception just to log and continue as if nothing happened
catch (Exception ex) { logger.LogError(ex, "Error"); }

Catching Exception is acceptable at boundaries — the global exception handler, the top of a background job loop, a message consumer — where you log and stop that unit of work safely.

Rule 2: Rethrow with throw;, not throw ex;

catch (SqlException ex)
{
    logger.LogWarning(ex, "Database error while saving order {OrderId}", order.Id);
    throw;        // ✅ preserves the original stack trace
    // throw ex;  ❌ resets the stack trace to this line — you lose where it really happened
}

When you translate an exception, pass the original as innerException:

catch (HttpRequestException ex)
{
    throw new PaymentGatewayException("Payment provider is unavailable.", ex);
}

Rule 3: Use exception filters

Filters (when) let you catch only under specific conditions — without unwinding the stack for the rest:

catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
    await Task.Delay(retryDelay);
}

catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 2627 or 2601 })
{
    throw new DuplicateEmailException(email, ex);
}

Filters are cleaner than catching, checking and rethrowing, and they keep stack traces intact.

Rule 4: Don't use exceptions for normal control flow

Exceptions are expensive (stack trace capture) and make code harder to follow when used for expected outcomes.

// ❌ Exception as an if-statement
try { var n = int.Parse(input); }
catch (FormatException) { n = 0; }

// ✅ Try-pattern
if (!int.TryParse(input, out var n)) n = 0;

// ✅ Expected "not found" → return null or a result
var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email);
if (user is null) return Result.Failure("User not found.");

Guideline: throw when a method can't fulfill its contract; return a value (null, bool, Result<T>) when failure is a normal, expected outcome.

Rule 5: Validate arguments early with guard clauses

public void Transfer(Account from, Account to, decimal amount)
{
    ArgumentNullException.ThrowIfNull(from);
    ArgumentNullException.ThrowIfNull(to);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
    if (from.Id == to.Id)
        throw new ArgumentException("Cannot transfer to the same account.", nameof(to));

    // ...
}

Modern .NET provides static throw helpers (ThrowIfNull, ThrowIfNullOrEmpty, ThrowIfNegative) that are concise and fast.

Custom exceptions

Create custom exceptions when callers need to react differently to a specific failure:

public class InsufficientStockException(int productId, int requested, int available)
    : Exception($"Product {productId}: requested {requested}, only {available} available.")
{
    public int ProductId { get; } = productId;
    public int Requested { get; } = requested;
    public int Available { get; } = available;
}

Guidelines:

  • Name them with the Exception suffix.
  • Derive from Exception (or a meaningful base), not ApplicationException.
  • Add properties with useful data — callers shouldn't parse messages.
  • Don't create one per method; group by meaning.

Cleanup with using and IDisposable

Resources such as files, streams, database connections and HTTP responses must be released even when an exception occurs. using generates the try/finally for you:

await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
var content = await reader.ReadToEndAsync();
// disposed automatically at the end of the scope, even on exceptions

Implementing IDisposable correctly matters for classes that own unmanaged or disposable resources — but most application classes don't need it.

Exceptions in async code

try
{
    await Task.WhenAll(SendEmailAsync(), SendSmsAsync());
}
catch (Exception ex)
{
    // `await` rethrows only the first exception
    logger.LogError(ex, "Notification failed");
}

To inspect all failures from Task.WhenAll, keep a reference to the returned task:

var all = Task.WhenAll(tasks);
try { await all; }
catch
{
    foreach (var inner in all.Exception!.InnerExceptions)
        logger.LogError(inner, "Task failed");
}

And remember: exceptions thrown inside async void methods can't be caught by the caller and may crash the process. Use async Task.

Handling cancellation

OperationCanceledException (and TaskCanceledException) usually mean the user or the server requested cancellation — not a bug:

catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    logger.LogInformation("Request was cancelled by the client");
}

Don't log these as errors; they create noise in your monitoring.

Global handling in ASP.NET Core

In web applications, most exceptions should bubble up to a single global handler (IExceptionHandler + UseExceptionHandler) that logs once and returns a safe ProblemDetails response. That keeps controllers and services free of repetitive try/catch blocks.

Quick reference

Do Don't
Catch specific exceptions catch (Exception) deep inside business code
throw; to rethrow throw ex;
Wrap with innerException lose the original error
Use filters (when) catch-check-rethrow
TryParse, null, Result<T> for expected cases exceptions for control flow
using / await using for resources manual cleanup without finally
Log once at the boundary log and rethrow at every layer

Summary

Throw exceptions when a method can't do its job, catch them only where you can do something useful, rethrow with throw;, wrap with inner exceptions to add context, use filters for conditional handling and using for cleanup. Keep expected outcomes out of the exception path, and let a global handler take care of the rest. Your code will fail loudly, informatively and safely.

FAQ

Are exceptions slow?

Throwing is relatively expensive, so avoid it in hot loops and for normal outcomes. In genuinely exceptional cases the cost is irrelevant.

Should every method have a try/catch?

No. Most methods should let exceptions propagate. Handle them at boundaries or where recovery is possible.

What about catch (Exception ex) when (Log(ex))?

A filter that logs and returns false lets you log without catching — a neat trick, but use it sparingly to keep code readable.