Back to Blog
C#

C# try catch: Syntax, Usage, and Pitfalls

c# try catch: Learn the practical syntax and behavior of the C# try-catch statement, including exception filters, finally, performance implications, and common mistakes.

exception handlingtry-catchC# error handlingfinally blockexception filters
Illustration of a C# try-catch block showing an exception being caught and handled with a finally block for cleanup.

The c# try catch statement is the primary mechanism for handling exceptions in C#. It lets you intercept a runtime failure, inspect its details, and decide whether to recover, retry, or let the exception propagate. Used correctly, it keeps error handling explicit and localized; used carelessly, it can hide bugs and degrade performance. This article covers the syntax, the behavior of each block, and the tradeoffs you need to consider when writing exception-handling code.

The Basic try-catch Syntax

A minimal try block contains code that may throw an exception. A catch block defines how to respond when a specific exception type occurs. The runtime unwinds the stack until it finds a matching catch handler, executes it, and then continues after the try-catch construct.

try { var data = File.ReadAllText("config.json"); Process(data); } catch (FileNotFoundException ex) { LogError($"Config file missing: {ex.Message}"); }

In this example, only a FileNotFoundException is caught. Any other exception, such as an UnauthorizedAccessException or an IOException, propagates up the call stack. The catch block receives the exception instance, giving you access to its properties like Message, StackTrace, and any inner exception.

You can also catch all exceptions with a parameterless catch block, but that is rarely the right choice because it hides the exception type and makes it impossible to handle different failures differently.

Catching Specific Exception Types

C# allows multiple catch blocks, each targeting a different exception type. The runtime evaluates them in order, so you must order them from most specific to most general. If a more general type appears first, the compiler warns you that later blocks are unreachable.

try { var connection = OpenDatabaseConnection(); connection.Execute(query); } catch (SqlException ex) when (ex.Number == 1205) { // Deadlock victim – retry logic } catch (SqlException ex) { // Other SQL errors } catch (Exception ex) { // Fallback for unexpected failures }

Here, the first catch handles a specific SQL error code using an exception filter. The second handles all other SqlException instances. The final catch catches any remaining exception. This pattern lets you apply targeted recovery without losing the ability to handle unexpected failures.

Exception Filters: The when Clause

C# 6 introduced exception filters with the when keyword. A filter evaluates a boolean expression before the catch block runs. If the expression returns false, the runtime continues searching for another matching handler. This is useful when you need to inspect exception properties or external state to decide whether a handler applies.

try { var result = await CallExternalServiceAsync(); } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests) { await Task.Delay(RetryDelay); return Retry(); }

Filters differ from simply checking the exception inside the catch block in one important way: if the filter expression throws an exception, that new exception replaces the original one. The runtime does not treat a filter failure as a match, and the exception propagates as if the filter never existed. Keep filter expressions simple and side-effect free to avoid masking the original failure.

The finally Block and Resource Cleanup

A finally block runs whether the try block completes normally or an exception is thrown. It is the correct place to release resources that are not automatically managed, such as file handles, network connections, or database sessions. The using statement provides a more concise way to manage IDisposable resources, but finally remains useful when you need cleanup logic that is not tied to a single object's lifetime.

SqlConnection connection = null; try { connection = new SqlConnection(connectionString); connection.Open(); RunQuery(connection); } finally { connection?.Close(); }

Because finally executes even when an exception propagates, it ensures cleanup happens before the exception reaches the caller. If the finally block itself throws an exception, that exception replaces any in-flight exception, which can make debugging confusing. Avoid throwing from finally unless you have no other option.

When to Use try-catch vs. Other Patterns

Not every method needs a try-catch. Exceptions are for exceptional conditions, not for control flow. If you expect a failure to be common, consider returning a result object or using the Try* pattern that many .NET APIs use, such as int.TryParse or Dictionary.TryGetValue.

if (int.TryParse(input, out var number)) { Use(number); } else { // Expected invalid input – no exception thrown }

A try-catch is appropriate when a failure is genuinely unexpected, when you need to translate a low-level exception into a domain-specific one, or when you must perform cleanup that cannot be expressed with a using block. Overusing exceptions for expected conditions makes code harder to read and slower at runtime.

Performance and Operational Considerations

Throwing and catching an exception is expensive compared to a simple branch. The runtime must allocate an exception object, capture a stack trace, and walk the stack to find a matching handler. In high-frequency code paths, avoid using exceptions to signal expected outcomes. Instead, use return codes or nullable result types.

Another operational concern is exception swallowing. A catch block that logs and continues may hide critical failures. Log the exception with enough context—including the exception type, message, and stack trace—so that production issues can be diagnosed. Do not log the exception and then rethrow the same exception unless you intend to add context; rethrowing with throw; preserves the original stack trace, while throw ex; resets it.

try { Process(); } catch (Exception ex) { LogError(ex); throw; // preserves original stack trace }

Finally, be aware that exception filters can affect performance. A filter that performs heavy work runs on every exception that reaches that handler, even if the filter ultimately returns false. Keep filters lightweight to avoid adding latency to the error path.

Common Mistakes and How to Avoid Them

One frequent mistake is catching a broad exception type and then not rethrowing. This turns every failure into a silent no-op, making the application appear to work while data is lost or state becomes inconsistent. If you cannot handle the exception meaningfully, let it propagate.

Another mistake is using catch (Exception ex) and then accessing ex.Message without considering that the exception may be an AggregateException or a TargetInvocationException wrapping the real failure. Use ex.InnerException and ex.GetBaseException() to unwrap nested exceptions when needed.

A third issue is placing a try-catch inside a loop where each iteration can throw. The overhead of exception handling is multiplied, and the code becomes harder to follow. If a loop operation can fail for a specific reason, handle that condition before the loop or use a retry policy that catches outside the loop.

Finally, remember that catch blocks are not a substitute for validation. Check input parameters and state before performing operations that could throw. A well-placed if prevents an exception from ever being thrown, which is almost always faster and clearer than catching one.

c# try catch: Practical Usage and Code Examples | RYUSLOG DEV