Last Updated: May 22, 2026
The try-catch block is the construct C# provides for reacting when an operation throws an exception. The _Exception Basics_ lesson covered what exceptions are and how they propagate up the call stack. This lesson covers the smallest useful piece of handling: one try block paired with one catch block. Multiple catches, finally, and the rest of the section build on this shape.
A try-catch is two adjacent blocks: a try block holding the code that might throw, and a catch block that runs only when an exception comes out of the try. Both blocks use braces. There's no condition, no parentheses on the try, and no return value. It's a statement, not an expression.
The try block contains a call to int.Parse, which throws FormatException when the input isn't a valid integer. The catch block catches that exception, prints a message, and the program keeps running. The line Console.WriteLine("Done."); runs after the catch finishes, so the program ends normally instead of crashing.
The exact text of ex.Message varies slightly across .NET versions. On .NET 8 and later, the message is the first line shown above. On older runtimes it's the second-line wording. Either way, the structure is the same: the try block protected the parser call, the exception didn't escape, and control resumed below the catch.
The phrase "the try block protects the code inside it" doesn't mean the code can't throw. It means that if the code does throw, the runtime will look at the attached catch blocks for a match instead of immediately tearing through stack frames. The try block doesn't prevent exceptions, it gives the program a chance to react to them.
The same shape without an exception in the happy path:
The parser succeeds, the try block finishes normally, and the runtime skips the catch entirely. The catch block is only entered when an exception is actually thrown inside the try. If the try body completes without throwing, control flows past the catch as if it weren't there.
That's the core control-flow rule, and the rest of this lesson is mostly variations on it. The two paths through a try-catch look like this:
Three observations. First, in the happy path (no throw), the catch body never runs. Second, even when an exception is thrown, the catch only runs if its declared type matches. Third, when nothing matches, the exception keeps going up the call stack to the next try-catch above this one, exactly as if the local try-catch weren't there for that exception type.
A try block cannot stand alone. It has to be followed by at least one catch, or a finally, or both. Writing try { ... } with nothing after it is a compile error (CS1524). The compiler enforces that "I want to protect this code" comes with at least one statement of "and here's what to do if something goes wrong" or "and here's cleanup that always runs." The _finally Block_ lesson covers finally, so for the rest of this lesson every try is paired with at least one catch.
Writing catch (Exception ex) catches everything, because Exception is the base class of every exception type in .NET. That's convenient for the first example, but it's the wrong default for real code. The purpose of typed exceptions is that different problems show up as different types, and a handler usually knows how to deal with one specific kind, not "anything that could possibly go wrong."
Replace the broad catch with a specific type:
The catch (FormatException ex) clause only matches exceptions of type FormatException (or types derived from it). int.Parse throws FormatException for unparseable input, so the match succeeds and the catch runs. The handler is now narrow: it triggers for parse failures and nothing else.
The same code with a different failure mode. int.Parse(null) throws ArgumentNullException, not FormatException:
The catch was looking for FormatException. The thrown exception was ArgumentNullException. The types don't match, so the catch is skipped and the exception keeps propagating. There's no other try-catch above this one, so the runtime walks all the way to the top of the stack, prints the trace, and terminates the process.
A try-catch only handles what its catches declare. Anything else flows through it like it isn't there.
Specific types allow different handlers for different problems. Even when this lesson only allows one catch block per try, the type chosen determines which exceptions are handled and which propagate:
(The throw new ArgumentNullException(...) is showing what's possible.)
In this example, the call succeeds, no exception is thrown, and the catch is never entered. Passing a malformed string like "abc" makes decimal.Parse throw FormatException and the catch matches. Passing null makes the method throw ArgumentNullException, which doesn't match the catch and propagates out, like in the earlier program.
That asymmetry, "handle parse failures but not null arguments," is expressible with a specific catch type and not with catch (Exception). The base-class catch swallows everything, including bugs that should crash the program instead of being logged.
The try block has near-zero overhead in the happy path. When no exception is thrown, the cost of wrapping code in a try is a few machine instructions to register the protected region with the runtime, which the JIT typically inlines away. The expensive part happens when an exception is actually thrown: capturing the stack trace, walking the stack, and unwinding frames. Exceptions should be rare, not part of normal control flow.
The catch type doesn't have to be the most specific type the call could throw. It has to be a type the thrown exception is assignable to. Catching IOException matches FileNotFoundException, DirectoryNotFoundException, and any other I/O error that derives from IOException. Catching SystemException matches almost everything, since most BCL exceptions derive from it. A narrower type catches fewer unexpected cases.
The expression inside the parentheses on the catch clause has two parts: the type to match, and an optional name to bind the caught exception object to. The name behaves like a method parameter. It's a local variable scoped to the catch block.
The name ex is the variable. Inside the catch block, ex is a strongly typed reference to the caught FormatException instance. Its properties can be read, methods called, the exception logged, inspected, anything possible with any other object reference. The compiler knows the type from the catch clause, so IntelliSense and overload resolution work normally.
The catch parameter follows the same rules as method parameters with one important difference: it's read-only inside the catch in the sense that assigning to it is unusual. C# technically allows ex = somethingElse;, but it's a code smell rarely seen in practice. Treat the catch variable as a fixed reference to the exception that was caught.
The scope of ex is the body of the catch block. It's invisible inside the try block above it, and it's invisible after the catch ends. The compiler rejects any reference outside that range.
The compiler rejects the commented-out line with error CS0103: The name 'ex' does not exist in the current context. The catch parameter lives and dies with the catch block. To use exception information after the catch finishes, copy it into a variable declared outside the try-catch:
The variable error is declared in the outer scope (the body of Main), so it survives past the catch. Inside the catch, the relevant piece of the exception is copied into that variable. Once the catch ends, the exception object itself is no longer reachable by name, but the copy lives on.
The name ex is convention. Common alternatives include e, ex, exception, or a more descriptive name when multiple catches need to be distinguishable in code reviews. ex is the most common and the shortest.
The variable is now called badFormat. The behavior is identical. Pick a name that reads well in the catch body and move on.
When the exception object isn't needed, drop the variable name and write only the type:
The catch clause catch (FormatException) has no parameter name. It still matches any FormatException thrown inside the try, but the caught object isn't bound to a variable. The catch body can't refer to ex.Message or ex.StackTrace because there is no ex. All it knows is "a FormatException happened."
This shape applies when the type itself is the only piece of information needed. In this example, the program reacts to "the user gave a non-numeric discount" by falling back to zero. The exact wording of the message isn't useful, so there's no reason to bind the object.
Dropping the type and writing a bare catch { } catches any exception at all. The compiler accepts it, but the behavior is identical to catch (Exception). Both forms catch every exception that comes through the try block.
The bare catch { } clause has no type and no variable. It catches every exception, swallows it, and prints a generic message. The program continues as if nothing happened.
This shape is dangerous in real code and should be avoided as a default. Catching everything and doing nothing useful with it (sometimes called "swallowing" the exception) hides bugs. A NullReferenceException in a stock-check method gets ignored, and the program proceeds with bad data instead of failing loudly. A real production crash from OutOfMemoryException gets caught and "handled" with a Console.WriteLine, leaving the process in an unknown state.
The other problem with catch-all is that it includes exceptions almost certainly unrecoverable: StackOverflowException, ThreadAbortException (in older runtimes), and serious BCL bugs. Catching them and pretending things are fine usually makes the problem worse, not better.
The general rule is to catch what the handler knows how to handle. If a try block can throw FormatException and OverflowException, write catches for those two and let anything else propagate. The full set of best practices lives in the _Exception Best Practices_ lesson, but the short version starts here: catch { } is a code smell. If using it, identify the specific failure to handle and catch that type instead.
One legitimate use of a bare catch: at the very top of a process (think Main, an event loop, a request handler) where the goal is to log the failure and exit cleanly rather than show a stack trace to the user. Even there, most teams prefer catch (Exception ex) for writing the message and the trace into a log.
That's the shape to remember: a specific type, no parameter name when the object isn't needed, and a concrete reaction. The empty-body, no-type, no-name version is a tool of last resort.
Variables declared inside a try block follow ordinary block-scoping rules. They live and die with the block, just like variables declared inside an if, a for, or any other braced block. They are not visible inside the catch, and they are not visible after the try-catch ends.
The variable stock is declared inside the try. Both commented-out lines fail to compile because stock's scope ends with the closing brace of the try body. Referencing it from the catch or from after the try-catch produces error CS0103.
This is a common surprise when a value computed inside the try needs to be used after the try ends. Declaring the variable inside the try, near where the value is produced, leaves no name accessible outside.
The fix is to declare the variable in the enclosing scope and assign to it inside the try:
Now stock is declared in the body of Main, which is the enclosing scope of both the try and the catch. The try assigns to it, the catch can assign to it as a fallback, and the code after the catch can read it. The variable's lifetime spans all three regions.
The scoping is shown below:
Anything declared in the outer scope is visible in the try, in the catch, and after both end. Anything declared inside the try is visible only inside the try. The catch parameter is visible only inside the catch. Variables declared inside the catch (besides the catch parameter) follow the same rule: visible only inside the catch.
That structure has a useful consequence. If the try body throws partway through, any variables declared inside the try are abandoned along with the partial work. The runtime unwinds the try block's stack frame, and the catch starts with a clean slate.
itemPrice and quantity are both declared inside the try. When int.Parse throws, the line that would set cartTotal is skipped, and the two locals go out of scope. The catch can't read them (they're not visible), and the code after the catch sees cartTotal at its initial value of 0m.
This is one reason "declare outside, assign inside" is the standard pattern when a value needs to survive the try. The other is that it makes the contract obvious to a reader: "this variable exists no matter what happens in the try, even on the failure path."
Both inputs parse cleanly, the catch is skipped, and the subtotal is computed from the values assigned inside the try. If either parse had failed, the catch would have run and the defaults (0m and 0) would have produced a subtotal of $0.00.
When an exception is caught and the catch body finishes, control resumes at the statement after the try-catch. It doesn't go back into the try, it doesn't restart anything, it moves on to whatever comes next in the surrounding code.
The order shows the control flow clearly. The try runs until the throw. The line right after the throw is never executed, because the throw transfers control to the matching catch. The catch runs to completion. Then the program continues at the statement after the catch, which prints After catch..
The path matters: the throw doesn't return to the try, and the line after a throw in the try is unreachable from the throw site.
If the try body finishes without throwing, the catch is skipped and control still ends up at the same place: the statement after the catch. The line after the try-catch runs in both cases, by different paths.
The first call runs the try body cleanly, skips the catch, and reaches After try-catch for '12'. The second call throws inside the try, runs the catch, and reaches After try-catch for 'oops'. The line after the try-catch runs in both cases, by different paths.
The catch is not a loop. If the catch body itself throws a new exception, that new exception propagates out the same way an uncaught exception would; the catch is not re-entered. The _finally Block_ and _Exception Best Practices_ lessons cover "what if the catch fails too."
The catch is also not an else. There's no syntactic mirror of "run this if the try body succeeded." For code that only runs on success, put it inside the try at the end:
The success-only Console.WriteLine sits inside the try, below the operations that might throw. If either parse throws, the success line is skipped because the throw transfers control directly to the catch. If both succeed, the line runs as part of the normal try body.
This pattern (do the risky work first, do the on-success work last, all in the same try) is the standard shape when there's no separate "rollback" step needed. The finally lesson covers a different shape that runs on both paths.
When the catch is the appropriate place for follow-up work, the catch parameter can be used to look up details and decide what to do.
The variable succeeded is assigned on both paths (try and catch), so by the time the code below the try-catch reads it, the value reflects which path was taken. That's the most common way to plumb "did the protected work succeed?" out of a try-catch block without using exceptions for control flow elsewhere.
The "boolean flag" pattern above is fine for one-off checks, but stacking it for every protected call calls for a redesign. Exceptions are for exceptional conditions; if a failure is expected often enough that flags are needed everywhere, a bool TryDoTheThing(...) method that returns a result is usually cleaner.
A try-catch only handles exceptions whose runtime type matches one of the declared catch types. If the thrown exception doesn't match, the catch is skipped and the exception keeps propagating up the call stack to the next try-catch above it, exactly as if the local try-catch weren't there for that exception type.
Inner has a try-catch, but the catch only matches FormatException. The thrown exception is ArgumentNullException, which doesn't match, so the catch is skipped and Inner never reaches its Console.WriteLine("Inner finished normally.") line. The exception leaves Inner and is now searching for a handler in the caller.
In Main, there's another try-catch, and this one's catch matches ArgumentNullException. The catch in Main runs, prints the type name, and control resumes after the catch with Main finished.. The outer handler caught what the inner one couldn't.
If Main didn't have a matching catch either, the exception would walk all the way to the top of the stack and the runtime would terminate the process with an unhandled-exception stack trace. That shape appeared in the earlier example with the null input to int.Parse.
The lookup for a matching catch is done by the runtime, in order, but with only one catch per try in this lesson there's nothing to order. The mechanics of multiple catches and how the runtime picks among them are the subject of the _Multiple Catch Blocks_ lesson.
A try-catch with no matching handler is invisible to the exception. The exception flows through it the same way it would flow through a plain block of code. The unmatched try-catch delays propagation by a few nanoseconds while the runtime checks the catch type. The cost is small but the behavior is what matters: an unmatched catch is the same as no catch at all.
Two nested try-catch blocks. The inner one declares catch (FormatException), which doesn't match the InvalidOperationException being thrown. The exception escapes the inner block, the outer block's catch matches, and the outer handler runs.
The inner catch is effectively skipped. It's not that the runtime "tries" the inner catch and fails. The runtime checks the type, sees no match, and immediately keeps propagating. The inner Console.WriteLine inside the catch body never gets a chance to run.
When reading code, mentally check each try-catch's catch type against the exception types the protected code could throw. If none of the catches cover a given type, that type leaks out of the try-catch and goes looking for a handler somewhere else, whether that's a higher-level try-catch in the caller or the runtime's unhandled-exception path.
The first call succeeds. The second triggers a FormatException which ParsePrice's catch absorbs, returning 0m. The third triggers an ArgumentNullException, which the catch in ParsePrice doesn't match, so it leaves the method and is caught by Main's outer catch. The line that would have printed Prices: ... never runs, because Main's try body was interrupted by the unhandled-here exception leaving ParsePrice.
This is the layered model: each level catches what it knows how to handle, and lets the rest pass through. A try-catch is a filter, not a wall. The catch type decides what gets stopped and what flows on.