Updated on

Use throw;. It rethrows the exception we caught and keeps the stack trace pointing at the line that actually failed. throw ex; rethrows the same object but restarts the trace at the throw ex line, losing everything below it.

That lost part is the part we need. It is the frames between our catch block and the real failure: the third-party method, the null reference, the line number. Once throw ex; runs, no log and no debugger can get them back.

To download the source code for this article, you can visit our GitHub repository.

What Do throw and throw ex Actually Do?

Both statements rethrow the same exception object. The difference is what happens to its stack trace on the way out.

throw;, the bare form valid only inside a catch block, passes the exception along untouched. The trace still names the method that originally threw, and every frame between there and here.

throw ex; treats the caught exception as a brand-new throw. Microsoft’s analyzer rule CA2200 puts it exactly: “the stack trace is restarted at the current method”, so the caller sees the exception as though it originated at the throw ex; line, and every frame below that point is lost.

The consequence is a debugging one, not a correctness one. Both versions compile, both propagate the same exception type and message, and both look identical in a catch upstream. The difference only appears in the log, at three in the morning, when the trace stops at a method that did not fail.

Rethrowing the Original Exception – The Standard Way

We’re all familiar with this scenario – we have some code that might throw an exception. We want to act upon this exception (a.k.a. handle it), for example by cleaning up some resources or logging certain data. This does not mean we want to hide the exception – quite contrary, we want it to bubble up, that’s why we want to rethrow the exception:

public class BusinessWorker
{
    public void Work_Throw()
    {
        try
        {
            //lots of other business logic all around...
            new ThirdPartyComponent().DoInternalWork();
        }
        catch (Exception ex)
        {
            //here we would handle 'ex' in the BusinessWorker (clean up resources, log state, call 911 etc.)
            throw;
        }
    }
}

This is the standard way of doing it, and we can be certain that any code that uses our BusinessWorker will receive all the available details about an exception. The tests here use NUnit, so Assert.AreEqual() is the assertion we reach for; if we copy them into an xUnit project, the assertion names change:

public void ThrowBehaviour_KeepsProperStackTrace()
{
    try
    {
        new BusinessWorker().Work_Throw();
    }
    catch (Exception ex)
    {
        Assert.AreEqual(
            @"System.InvalidOperationException: That's a nasty bug!
at ThrowVsThrowEx.ThirdPartyComponent.<GoDeeper>g__DoDangerousOperation|1_0()
at ThrowVsThrowEx.ThirdPartyComponent.GoDeeper()
at ThrowVsThrowEx.ThirdPartyComponent.DoInternalWork()
at ThrowVsThrowEx.BusinessWorker.Work_Throw()
at ThrowVsThrowEx.ThrowVsThrowExSamples.ThrowBehaviour_KeepsProperStackTrace()",
            ex.ToString());
    }
}

We see the exception and we immediately know that something has happened deep within a 3rd party component. For the wider picture around this, see our guide to exception handling in C#.

Rethrowing the Original Exception – The Incorrect* Way

The asterisk after “incorrect” is not accidental – there are valid situations where this is the desired behavior, and we come back to them at the end.

Now, what would happen if our code was only slightly different:

public class BusinessWorker
{
    public void Work_ThrowEx()
    {
        try
        {
            //lots of other business logic all around...
            new ThirdPartyComponent().DoInternalWork();
        }
        catch (Exception ex)
        {
            //here we would handle 'ex' in the BusinessWorker (clean up resources, log state, call 911 etc.)
            throw ex;
        }
    }
}

Here we have used the throw ex statement instead of throw. The effect on the stack trace of our exception is dramatic:

public void ThrowEx_DropsTheStackTrace()
{
    try
    {
        new BusinessWorker().Work_ThrowEx();
    }
    catch (Exception ex)
    {
        Assert.AreEqual(
            @"System.InvalidOperationException: That's a nasty bug!
at ThrowVsThrowEx.BusinessWorker.Work_ThrowEx()
at ThrowVsThrowEx.ThrowVsThrowExSamples.ThrowEx_DropsTheStackTrace()",
            ex.ToString());
    }
}

Yes, dramatic is not an exaggeration, if we consider the time spent on looking for a nasty bug in the BusinessWorker.Work() method, instead of knowing right away where it happened. The same care applies when we are catching multiple exception types and rethrowing from one of several handlers.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

Does throw ex Reset the Stack Trace?

Yes, and the traces printed above show exactly how much is lost.

The throw; version lists five frames, starting inside the third-party component where the failure actually happened. The throw ex; version lists two, starting at our own BusinessWorker method. Three frames, including the one naming the real bug, are gone.

This is why the compiler ships a rule for it. The .NET analyzers flag throw ex; inside a catch block precisely because it discards stack details, and the fix the rule suggests is to delete the two characters.

There is one objection worth answering. throw; is often said to overwrite the line number of the frame it rethrows from, since the rethrow point sits in that same method. On .NET 10 it does not: a method that catches and rethrows still reports the line that failed, not the line holding the throw;. Frames and line numbers both survive.

The rule is CA2200, “Rethrow to preserve stack details”, category Usage, and it is enabled by default in .NET 10 as a warning (Microsoft Learn, read 9 August 2026). Building this article’s sample on net10.0 confirms it: the compiler raises warning CA2200: Re-throwing caught exception changes stack information on the throw ex; line.

Wrapping The Original Exception And Throwing A New One

There is one more widely popular method for handling exceptions, especially in large systems. Exceptions can be wrapped into custom domain-specific exception types, in order to make debugging simpler in complex scenarios:

public class BusinessWorker
{
    public void Work_WrapAndThrowNewEx()
    {
        try
        {
            //lots of other business logic all around...
            new ThirdPartyComponent().DoInternalWork();
        }
        catch (Exception ex)
        {
            //here we would handle 'ex' in the BusinessWorker (clean up resources, log state, call 911 etc.)
            throw new BusinessException("I am a business domain wrapper for internal exceptions.", ex);
        }
    }
}

In this case, we are wrapping the original exception into a new exception type – BusinessException. What happens with our stack trace when we throw a new exception?

public void WrapAndThrowNewEx_KeepsTheStackTraceInTheInnerException()
{
    try
    {
        new BusinessWorker().Work_WrapAndThrowNewEx();
    }
    catch (Exception ex)
    {
        //the stack trace of the top level exception is short
        Assert.AreEqual(@"   at ThrowVsThrowEx.BusinessWorker.Work_WrapAndThrowNewEx()
at ThrowVsThrowEx.ThrowVsThrowExSamples.WrapAndThrowNewEx_KeepsTheStackTraceInTheInnerException()", ex.StackTrace);
        //however, the actual exception and it's stack trace is visible within the ex.InnerException property
        //the full exception string also reveals all the layers of inner exceptions
        Assert.AreEqual(
            @"ThrowVsThrowEx.BusinessWorker+BusinessException: I am a business domain wrapper for internal exceptions.
---> System.InvalidOperationException: That's a nasty bug!
at ThrowVsThrowEx.ThirdPartyComponent.<GoDeeper>g__DoDangerousOperation|1_0()
at ThrowVsThrowEx.ThirdPartyComponent.GoDeeper()
at ThrowVsThrowEx.ThirdPartyComponent.DoInternalWork()
at ThrowVsThrowEx.BusinessWorker.Work_WrapAndThrowNewEx()
--- End of inner exception stack trace ---
at ThrowVsThrowEx.BusinessWorker.Work_WrapAndThrowNewEx()
at ThrowVsThrowEx.ThrowVsThrowExSamples.WrapAndThrowNewEx_KeepsTheStackTraceInTheInnerException()",
            ex.ToString());
    }
}

That’s right – we are not losing any information, however, some of the data is now segregated into the InnerException of the BusinessException. Wrapping is the shape we want at an application boundary, which is also where handling exceptions globally with IExceptionHandler and serializing exceptions as JSON come into play.

How Do We Rethrow an Exception From Another Method?

throw; only works inside a catch block. When we have stored an exception and want to rethrow it later (from a different method, after awaiting something, out of a background worker), the bare form is not available.

throw ex; is the obvious substitute and it is the wrong one, for the reason above.

The right tool is ExceptionDispatchInfo. We capture the exception where we catch it, carry the capture wherever it needs to go, and call Throw() on it. The original stack trace survives, and the new frames are appended rather than replacing it, under a --- End of stack trace from previous location --- marker.

Is this material useful to you? Consider subscribing and get ASP.NET Core Web API Best Practices eBook for FREE!

This is what the framework itself does. When an await rethrows the exception from a faulted task, it is not calling throw ex;: it is dispatching a captured exception, which is why an async stack trace still names the method that failed.

CA2200’s own page recommends the same tool this section does: if you rethrow from outside the handler, “use ExceptionDispatchInfo.Capture(Exception) to capture the exception in the handler”.

In code, that is five lines:

ExceptionDispatchInfo? captured = null;

try { new ThirdPartyComponent().DoInternalWork(); }
catch (Exception ex) { captured = ExceptionDispatchInfo.Capture(ex); }

captured?.Throw();   // original stack trace intact, here

The rethrow happens outside the catch block, which is the whole point, and the trace still opens on DoDangerousOperation() rather than on the line that called Throw().

When To Use Which Approach?

A rule of thumb for most cases would be to use the approach that does not hide the important debugging info – therefore, either go with “throw” or, if needed, wrap the exception. The exceptional case would be when we do not want to reveal the actual stack trace.

This is however an edge case and there are other measures that can be taken to prevent inner details from leaking. Often the better move is not to catch the exception at all, which is what filtering exceptions with the when keyword gives us.

throw;throw ex;throw new X("...", ex);ExceptionDispatchInfo
Exception type seen by the callerOriginalOriginalNew wrapper typeOriginal
Original stack tracePreservedRestarted at the rethrow pointPreserved, in InnerExceptionPreserved
Original exception reachableIt is the exceptionIt is the exceptionVia InnerExceptionIt is the exception
Valid outside a catch blockNoYesYesYes
Analyzer warningNoneCA2200NoneNone
Use it whenHandling then letting it continueAlmost neverAdding domain context at a boundaryRethrowing a captured exception later or elsewhere

Conclusion

We have briefly touched upon three basic methods for rethrowing an exception in C# and what implications each approach has, plus the fourth one for rethrowing outside a catch block. For a more general overview, see our article on Exception Handling in C#.

Tested with .NET 10.0.10.