Try-Catch Blocks - Handling Exceptions Gracefully

Vaibhav • September 10, 2025

In the previous article, we introduced the concept of exceptions-runtime events that signal something has gone wrong. You saw how exceptions differ from regular errors, and why handling them is essential for writing robust, production-grade software. Now it’s time to explore the core mechanism C# provides for dealing with exceptions: the try and catch blocks.

These blocks form the backbone of structured exception handling in C#. They let you isolate risky code and respond to failures in a controlled way. In this article, we’ll walk through how each block works, how exceptions propagate, and how to write handlers that are both safe and intentional. We’ll avoid discussing finally here, as it’s covered in a dedicated article later in this chapter.

What is a Try-Catch Block?

A try-catch block is a control structure that wraps code which might throw an exception. If an exception occurs inside the try block, control jumps to the matching catch block. If no exception occurs, the catch block is skipped entirely.

// Basic try-catch example
try
{
    int x = 10;
    int y = 0;
    int result = x / y; // This will throw DivideByZeroException
    Console.WriteLine("Result: " + result);
}
catch (DivideByZeroException)
{
    Console.WriteLine("Cannot divide by zero.");
}

In this example, the division by zero triggers a DivideByZeroException. The catch block intercepts it and prints a friendly message instead of crashing the program. If y had been a non-zero value, the catch block would have been skipped.

You can catch specific exception types to handle different failure modes differently. This makes your error handling more precise and intentional.

Multiple Catch Blocks

Sometimes, different exceptions require different responses. C# lets you stack multiple catch blocks to handle each type separately. The runtime matches the first compatible block and skips the rest.

try
{
    string input = null;
    Console.WriteLine(input.Length); // NullReferenceException
}
catch (DivideByZeroException)
{
    Console.WriteLine("Math error.");
}
catch (NullReferenceException)
{
    Console.WriteLine("Object was null.");
}
catch (Exception ex)
{
    Console.WriteLine("Unexpected error: " + ex.Message);
}

Here, the NullReferenceException is caught by its matching block. If the exception had been something else, like FormatException, it would fall through to the generic Exception block.

Always place more specific catch blocks before general ones. Otherwise, the general block will intercept exceptions that could have been handled more precisely.

Inspecting the Exception Object

Every exception object carries useful information: a message, a stack trace, and sometimes inner exceptions. You can access these via the Exception parameter in your catch block.

try
{
    int[] numbers = new int[2];
    Console.WriteLine(numbers[5]); // IndexOutOfRangeException
}
catch (Exception ex)
{
    Console.WriteLine("Error: " + ex.Message);
    Console.WriteLine("Stack Trace: " + ex.StackTrace);
}

This pattern is especially useful for logging and diagnostics. You can record the exact error and where it occurred, which helps during debugging or when analyzing crash reports. The Message property gives a human-readable description, and StackTrace shows where the exception originated.

Exception Propagation

If an exception isn’t caught in the current method, it propagates up the call stack until it finds a matching catch block. If none is found, the program terminates.

void Level1()
{
    Level2();
}

void Level2()
{
    Level3();
}

void Level3()
{
    throw new Exception("Boom!");
}

try
{
    Level1();
}
catch (Exception ex)
{
    Console.WriteLine("Handled at top level: " + ex.Message);
}

The exception thrown in Level3 bubbles up through Level2 and Level1 until it’s caught at the top level. This is called exception propagation. It allows you to centralize error handling in higher-level methods.

You can rethrow an exception using throw; inside a catch block. This preserves the original stack trace and lets higher-level handlers deal with it.

Throwing Your Own Exceptions

You’re not limited to catching exceptions-you can throw them too. This is useful when your method detects an invalid state and wants to signal failure to the caller.

void ValidateAge(int age)
{
    if (age < 0)
    {
        throw new ArgumentOutOfRangeException("Age cannot be negative.");
    }
}

This method checks for invalid input and throws an exception if the condition is violated. The caller can then catch and handle it appropriately. Throwing exceptions is a way to enforce contracts and signal serious problems that cannot be handled locally.

Common Mistakes to Avoid

Exception handling is powerful, but it’s easy to misuse. Here are some patterns to avoid:

// ❌ Catching all exceptions without handling
try
{
    // risky code
}
catch
{
    // silently ignore
}

// ❌ Catching Exception but doing nothing
catch (Exception)
{
    // no logging, no message
}

// ❌ Using exceptions for control flow
try
{
    int value = int.Parse("abc"); // throws FormatException
}
catch (FormatException)
{
    value = 0; // fallback
}

These patterns hide problems instead of solving them. Always handle exceptions intentionally, and never use them as a substitute for regular control flow. For example, prefer int.TryParse over catching FormatException when parsing user input.

Validate inputs before risky operations. Use exceptions for truly exceptional cases-not for routine checks or expected conditions.

Summary

In this article, you learned how to use try and catch blocks to handle exceptions in C#. You saw how to catch specific exception types, inspect exception objects, and throw your own when needed. You also learned how exceptions propagate through the call stack and how to avoid common pitfalls like catching everything or using exceptions for control flow.

Exception handling is a key part of writing resilient software. It lets your program respond to failures gracefully, preserve user experience, and maintain system integrity. In the next article, we’ll explore Finally Blocks: how to use them for cleanup, resource management, and ensuring code runs regardless of exceptions.