Preserving Stack Traces
If you've ever debugged an exception only to find the stack trace points to your catch block instead of where the error actually occurred, you've seen what happens when rethrowing loses diagnostic information. The throw keyword lets you rethrow exceptions without destroying their stack traces.
Use throw alone in a catch block to rethrow the current exception exactly as it was. This preserves the original stack trace, letting you see where the error started. Using throw ex creates a new throw point and loses that history. Understanding this distinction makes debugging production errors dramatically easier.
You'll build error handlers that log and rethrow exceptions correctly, preserving diagnostic data. By the end, you'll know when to rethrow, when to wrap, and how to maintain exception context through layers of code.
Rethrowing Correctly
Use throw without an argument to rethrow the caught exception with its original stack trace intact. This is the right way to log and propagate errors without hiding their source.
using System;
public class DataProcessor
{
public void ProcessData(string data)
{
try
{
var result = ParseData(data);
SaveResult(result);
}
catch (Exception ex)
{
Console.WriteLine($"Error during processing: {ex.Message}");
throw;
}
}
private int ParseData(string data)
{
return int.Parse(data);
}
private void SaveResult(int result)
{
if (result < 0)
throw new InvalidOperationException("Negative values not allowed");
}
}
The catch block logs the error then rethrows with bare throw. The stack trace shows ParseData or SaveResult as the origin, not the catch block. This gives you accurate debugging information about where things actually broke.
The throw ex Anti-Pattern
Throwing the exception variable resets the stack trace to the current location. This hides where the error originated and makes debugging harder.
using System;
public class BadExample
{
public void ProcessData(string data)
{
try
{
DoSomethingRisky(data);
}
catch (Exception ex)
{
Console.WriteLine($"Logging: {ex.Message}");
throw ex;
}
}
private void DoSomethingRisky(string data)
{
throw new InvalidOperationException("Something went wrong");
}
}
// Stack trace now points to the throw ex line,
// not to DoSomethingRisky where the problem occurred
Using throw ex loses the original stack trace. The exception now appears to originate from the catch block. This costs you valuable debugging information. Always use bare throw when rethrowing.
Wrapping Exceptions with Context
When crossing abstraction boundaries, wrap the original exception in a new one with added context. Set the InnerException to preserve the original cause and its stack trace.
using System;
public class FileProcessor
{
public void ProcessFile(string filePath)
{
try
{
var content = ReadFile(filePath);
ParseContent(content);
}
catch (Exception ex)
{
throw new ApplicationException(
$"Failed to process file: {filePath}",
ex);
}
}
private string ReadFile(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException("File not found", path);
return File.ReadAllText(path);
}
private void ParseContent(string content)
{
throw new FormatException("Invalid format");
}
}
The new ApplicationException adds file path context while preserving the original FileNotFoundException or FormatException in InnerException. Callers see the high-level error with details about what failed, and debuggers show both stack traces.
Try It Yourself
Build a simple calculator that demonstrates proper rethrowing and exception wrapping. This shows how to preserve stack traces while adding context.
Steps
- Create:
dotnet new console -n ThrowDemo
- Navigate:
cd ThrowDemo
- Edit Program.cs
- Update .csproj
- Run:
dotnet run
using System;
try
{
var calc = new Calculator();
calc.Divide(10, 0);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
Console.WriteLine($"Stack trace: {ex.StackTrace}");
if (ex.InnerException != null)
{
Console.WriteLine($"Inner: {ex.InnerException.Message}");
}
}
class Calculator
{
public double Divide(double a, double b)
{
try
{
return PerformDivision(a, b);
}
catch (Exception ex)
{
throw new InvalidOperationException(
$"Division operation failed for {a}/{b}",
ex);
}
}
private double PerformDivision(double a, double b)
{
if (b == 0)
throw new DivideByZeroException("Cannot divide by zero");
return a / b;
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Run result
Error: Division operation failed for 10/0
Stack trace: at Calculator.Divide...
Inner: Cannot divide by zero
Mistakes to Avoid
Swallowing exceptions silently: Catching and not rethrowing hides errors completely. If you can't handle an exception meaningfully, don't catch it. Let it propagate to code that knows how to deal with it. Empty catch blocks or catches that only log without rethrowing bury problems.
Using exceptions for control flow: Throwing exceptions for normal conditions like validation failures wastes performance. Use return values or result objects instead. Exceptions should signal unexpected errors, not expected branches in logic. Reserve throw for actual problems.
Throwing generic exceptions: Don't throw Exception or ApplicationException directly. Use specific exception types that describe the problem. Create custom exceptions when built-in types don't fit. Specific types let callers handle different errors appropriately and make debugging clearer.