Exception Propagation in C#

Vaibhav • September 10, 2025

In the previous article, we learned how to create custom exceptions to represent domain-specific errors. Now it’s time to understand how exceptions behave once they’re thrown - how they travel through your code, how they’re caught (or not), and what happens when they’re ignored. This process is called exception propagation, and it’s a critical part of writing robust, maintainable C# applications.

What is Exception Propagation?

Exception propagation refers to the way an exception moves up the call stack until it’s handled. When an exception is thrown inside a method, and that method doesn’t catch it, the runtime looks to the caller of that method. If the caller doesn’t catch it either, the search continues up the stack. If no method catches the exception, the program crashes.

The call stack is the sequence of method calls that led to the current point in execution. Exception propagation walks this stack in reverse, looking for a matching catch block.

Basic Example of Propagation

Let’s start with a simple example that shows how an exception thrown in one method can be caught in another:

void Level1()
{
    Level2();
}

void Level2()
{
    Level3();
}

void Level3()
{
    throw new InvalidOperationException("Something went wrong.");
}

try
{
    Level1();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine("Caught: " + ex.Message);
}

Here’s what happens:

  • Level3 throws an exception.
  • Level2 doesn’t catch it, so it propagates to Level1.
  • Level1 also doesn’t catch it, so it reaches the try block in Main.
  • The catch block matches the exception type and handles it.

If no catch block matches the exception type, the program terminates and shows an unhandled exception error.

How the Runtime Matches Exceptions

When an exception is thrown, the runtime looks for a catch block that matches the type of the exception. It checks each method in the call stack, starting from the point of the throw and moving outward.

Matching is based on type - the first catch block that matches the exception type (or a base type) will handle it. For example:

try
{
    DangerousOperation();
}
catch (Exception ex)
{
    Console.WriteLine("Handled as generic exception.");
}

This will catch any exception derived from Exception, including InvalidOperationException, ArgumentNullException, and your custom exceptions.

Multiple Catch Blocks

You can use multiple catch blocks to handle different exception types differently. The runtime picks the first matching block:

try
{
    DangerousOperation();
}
catch (ArgumentNullException ex)
{
    Console.WriteLine("Missing argument: " + ex.Message);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine("Invalid operation: " + ex.Message);
}
catch (Exception ex)
{
    Console.WriteLine("General error: " + ex.Message);
}

This structure lets you respond appropriately to different failure modes. The order matters - more specific exceptions should come first.

Rethrowing Exceptions

Sometimes you want to catch an exception, log it, and then let it continue propagating. You can do this using the throw keyword:

try
{
    DangerousOperation();
}
catch (Exception ex)
{
    Console.WriteLine("Logging: " + ex.Message);
    throw; // rethrow the same exception
}

This preserves the original stack trace and lets higher-level code handle the exception. Avoid using throw ex; - it resets the stack trace and makes debugging harder.

Use throw; (without a variable) to rethrow exceptions. This keeps the original context intact.

Exception Propagation with Custom Exceptions

Custom exceptions behave just like built-in ones when it comes to propagation. You can throw them deep in your code and catch them at higher levels:

public class ValidationException : Exception
{
    public ValidationException(string message) : base(message) { }
}

void ValidateUser(string name)
{
    if (string.IsNullOrWhiteSpace(name))
        throw new ValidationException("Name is required.");
}

try
{
    ValidateUser("");
}
catch (ValidationException ex)
{
    Console.WriteLine("Validation failed: " + ex.Message);
}

This lets you isolate validation logic while still giving the caller control over how to respond.

Exception Propagation and Method Design

When designing methods, decide whether they should handle exceptions or let them propagate. Here are some guidelines:

  • Low-level methods: Often throw exceptions and let callers decide how to handle them.
  • Mid-level methods: May catch and rethrow, or wrap exceptions in custom types.
  • Top-level methods: Should catch exceptions and present user-friendly messages or log errors.

Exception propagation lets you separate concerns - low-level code focuses on logic, while higher-level code handles presentation and recovery.

Exception Wrapping

Sometimes you want to catch an exception and throw a new one that adds context. This is called exception wrapping. You preserve the original exception using the InnerException property:

try
{
    SaveToDatabase();
}
catch (SqlException ex)
{
    throw new DataAccessException("Failed to save user data.", ex);
}

This gives you a higher-level error that still contains the original cause. It’s especially useful in layered architectures.

Unhandled Exceptions

If an exception reaches the top of the stack without being caught, the application crashes. In console apps, you’ll see an error message. In GUI or web apps, you may get a crash dialog or a generic error page.

You can catch unhandled exceptions using global handlers, but this should be a last resort:

AppDomain.CurrentDomain.UnhandledException += (sender, args) =>
{
    Console.WriteLine("Unhandled exception: " + args.ExceptionObject);
};

This lets you log or report the error, but you can’t recover from it. Always prefer catching exceptions closer to where they occur.

Exception Propagation in Asynchronous Code

In asynchronous methods, exceptions propagate through await. If an async method throws, the exception is stored in the returned Task and rethrown when you await it:

async Task DangerousAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("Async failure.");
}

try
{
    await DangerousAsync();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine("Caught async error: " + ex.Message);
}

This works just like synchronous propagation, but the exception is deferred until the task is awaited.

If you forget to await a task, its exception may go unnoticed. Always await tasks or handle their Exception property.

Designing for Propagation

Exception propagation is a powerful tool, but it requires thoughtful design. Here are some tips:

  • Let exceptions propagate when the caller can handle them better.
  • Catch exceptions when you can recover or add context.
  • Use custom exceptions to signal specific failure modes.
  • Log exceptions at boundaries (e.g., UI, service layer).
  • Don’t swallow exceptions silently - it hides bugs.

Design your methods with clear exception contracts. Document what exceptions they might throw and under what conditions.

Summary

Exception propagation is the mechanism by which C# handles errors across method boundaries. When an exception is thrown, it travels up the call stack until it’s caught - or crashes the program. Understanding how propagation works helps you design better error handling strategies, write cleaner code, and build more resilient applications.

You’ve learned how exceptions move through methods, how to catch and rethrow them, how to wrap them for context, and how to handle them in async code. In the next article, we’ll explore Multiple Catch blocks - how to use them effectively to handle different exception types and maintain clean error handling logic.