AlgoMaster Logo

Exception Basics

Last Updated: May 22, 2026

High Priority
14 min read

An exception is the runtime's way of signaling that something went wrong while the program was running. It's a distinct category from compile errors, which the compiler catches before the code ever runs, and it's the foundation everything else in this section builds on (try-catch, finally, custom exceptions, filters, and best practices). This lesson covers what an exception is, what lives inside an exception object, the type hierarchy, and what happens when nothing catches one.

What an Exception Is

An exception is an object. Specifically, it's an instance of System.Exception or one of its subclasses, created at the moment something goes wrong, and then handed up the call stack until either some code catches it or the runtime terminates the process. That object carries a message, a stack trace, and a few other pieces of context describing what happened and where.

The word "exception" describes the idea: this is the exceptional path, not the happy path. The happy path is "look up the product, add it to the cart, return the total." The exceptional path is "the product doesn't exist," "the cart is null," "the price field can't be parsed," "the stock count overflowed." When any of those happen, the normal sequence of statements stops, and control transfers to whatever piece of code is prepared to handle the failure.

Compile errors are different. A compile error means the source code doesn't pass the C# compiler. A program with compile errors can't run because the compiler never produced an executable. Calling a method that doesn't exist, returning the wrong type, forgetting a semicolon, those are compile errors. Exceptions, by contrast, happen at runtime, in a program that compiled fine. The code looks right on paper. The trouble shows up when real values flow through it.

The code compiled without complaint. The compiler had no reason to refuse: couponCode is typed as string, and reading .Length on a string is a normal operation. The failure showed up when the program ran and tried to dereference a null. At that moment, the runtime created a NullReferenceException object and threw it. Nothing caught it, so the process printed the message and terminated.

That's the shape of the mechanism. An operation fails, the runtime (or sometimes user code) constructs an exception object, that object propagates up the call stack until something catches it, and if nothing does, the process ends with a stack trace.

Exceptions separate the description of normal behavior from the handling of failure. Without exceptions, every method would have to return some kind of success/failure indicator, and every caller would have to check it. The code would spend more lines on error checking than on actual work. Exceptions keep the normal path clean and push failure handling to specific points where the appropriate response is known.

decimal.Parse is a method that promises to return a decimal. There's no place in its signature for "I couldn't do that." If the input doesn't parse, the only honest answer is to abandon the normal return path and throw. That's what a FormatException represents: a structured way to say "this method can't fulfill the contract its signature promised."

The Exception Hierarchy

Every exception in .NET is a class, and all of them descend from a single root: System.Exception. That base class lives at the top of a tree that fans out into hundreds of specific exception types, each one describing a particular kind of failure. The hierarchy matters because catching an exception by type determines which failures the handler addresses.

The root of everything in C# is System.Object, the universal base class. System.Exception derives from System.Object, and then the rest of the exception world derives from Exception. Two large branches sit directly under it: SystemException for failures coming from the runtime and the base class library, and (historically) ApplicationException for user-defined exceptions. Most concrete exception types, including NullReferenceException, ArgumentException, InvalidOperationException, and FormatException, descend from SystemException.

The diagram is a simplified slice. The real tree has many more nodes, but the shape is the same: one root, a couple of broad categories, and a wide spread of concrete leaves. Catching Exception catches everything. Catching ArgumentException catches ArgumentException itself and its derived types like ArgumentNullException and ArgumentOutOfRangeException. Catching ArgumentNullException catches only that one specific failure. The further down the tree the catch, the more precise the handler.

This hierarchy is reflected in real BCL APIs. A Dictionary<TKey, TValue> throws KeyNotFoundException when an indexer lookup misses, and KeyNotFoundException derives from SystemException. int.Parse throws FormatException for malformed input and OverflowException for values outside int.MinValue..int.MaxValue. A Stack<T> throws InvalidOperationException on Pop() against an empty stack. Each of these has a precise type so callers who care about that specific failure can catch exactly it, and callers who want to handle all failures uniformly can catch the broader base type.

The hierarchy is a tree, not a flat list, because of type-based dispatch. The catch mechanism uses standard inheritance rules: a catch (Exception ex) matches every exception type, because every exception is an Exception. A catch (ArgumentException ex) matches ArgumentException and ArgumentNullException but not FormatException, because FormatException isn't an ArgumentException. The tree shape allows a single handler to cover a family of related failures.

The thrown object is an ArgumentNullException, and the catch clause was written for ArgumentException. The exception still matches because ArgumentNullException is an ArgumentException by inheritance. The actual type of the caught object is ArgumentNullException, which GetType() confirms, and the is checks show it's all three: an ArgumentNullException, an ArgumentException, and an Exception. That's the hierarchy doing what it's designed for.

Knowing the position of each exception type in the tree is half the battle when reading code. The other half is knowing what each common concrete type means, covered next.

What Lives Inside an Exception Object

An exception is a regular .NET object, interacted with like any other. System.Exception defines a handful of public members that every derived exception inherits, and those members reveal what happened, where it happened, and what context the throwing code attached.

The important members are Message, StackTrace, Source, HelpLink, Data, and TargetSite. Each one answers a different question about the failure.

Message is a human-readable description of what went wrong, set when the exception is constructed. The runtime sets a reasonable default message for built-in exceptions ("Object reference not set to an instance of an object" for NullReferenceException), but code that throws its own exceptions can pass a custom message to the constructor.

StackTrace is a string showing the chain of method calls that led to the throw. It's the useful piece of context for debugging because it shows the path the program took to reach the failure, not just the location of the throw itself.

Source is the name of the application or assembly that caused the exception. The runtime fills it in automatically, though it can be overwritten when needed.

HelpLink is meant to hold a URL pointing at documentation explaining the exception. It's almost always empty in practice; teams rarely populate it.

Data is an IDictionary that allows attaching arbitrary key-value pairs to the exception. It's a convenient escape hatch for adding context (a product ID, a customer name, a request identifier) without subclassing the exception type.

TargetSite is a MethodBase describing the specific method where the exception was thrown. It complements StackTrace by providing direct reflection access to the method, not just a formatted string.

Output (paths and method names vary):

Every field on the exception is something the catch block can read. Message is appropriate in a log line for human scanning. StackTrace is what a developer investigating uses. Data is where to stash the specific business identifiers that turn a generic "cart is empty" failure into "the cart with ID C-7421 for customer U-118 was empty." None of these are mandatory; they're available when needed.

StackTrace deserves special attention because it isn't a simple string field hiding behind the property. It's computed on demand by walking the stack and reading debug metadata. The first read of ex.StackTrace causes the runtime to allocate a string, format the frames, and return it. Subsequent reads return the cached string. Constructing the trace isn't free, and code that throws and discards exceptions in a loop pays for that walk every time.

Capturing a stack trace requires the runtime to walk every frame from the throw site to the catch, then format each frame into text. For a deep call stack, that's hundreds of frames of work and a chunk of string allocation. Throwing is fine for exceptional cases. Throwing in a tight loop as a control-flow mechanism is one of the slowest things to do in .NET.

TargetSite provides the actual MethodBase for the throw site, useful for reacting to the failure programmatically. The declaring type, method name, and parameter list are all available. Most code never touches it, but reflection-driven logging or diagnostic tools can use the structured handle on the method.

The throw didn't come from decimal.Parse itself; it came from an internal helper named ThrowFormatException. TargetSite reveals that level of detail. For day-to-day debugging, the formatted StackTrace is more useful, but TargetSite is the structured equivalent for making decisions based on which method failed.

Data is the only mutable, untyped piece of context an exception offers. It's an IDictionary, so any key or value works as long as both sides are serializable (or serialization isn't a concern). The convention is to use string keys describing the context.

Data is convenient, but it isn't a substitute for a well-designed custom exception when the context is important. A custom exception with typed properties is cleaner than fishing string keys out of a dictionary. Use Data for incidental extras; use a custom exception type when the failure is a first-class concern in the domain.

Common Exceptions in the BCL

The base class library throws a handful of exception types repeatedly. Recognizing them by sight saves time when reading stack traces, because the type usually tells what went wrong without reading the message in detail.

The table below covers the common ones. Each row lists the exception, what triggers it, and the kind of code that tends to produce it.

ExceptionWhat It MeansTypical Trigger
NullReferenceExceptionDereferenced a null reference.Calling a method or accessing a member on a variable that's null.
ArgumentExceptionAn argument doesn't satisfy the method's requirements.Passing an empty string where a non-empty one is required.
ArgumentNullExceptionAn argument that must not be null is null.Passing null for a parameter the method explicitly forbids null.
ArgumentOutOfRangeExceptionA numeric or index argument is outside the allowed range.Passing a negative quantity to a method that expects a non-negative one.
IndexOutOfRangeExceptionAccessed an array or list with an index outside its bounds.arr[arr.Length], or list[-1].
InvalidOperationExceptionThe object's state doesn't allow this operation.Calling Pop() on an empty stack, or modifying a collection while iterating it.
FormatExceptionA string couldn't be parsed into the requested type.int.Parse("twelve"), DateTime.Parse("not a date").
DivideByZeroExceptionInteger division (or decimal division) by zero.int x = 10 / 0;, decimal y = 1m / 0m;.
OverflowExceptionAn arithmetic operation overflowed in a checked context, or decimal arithmetic overflowed.checked(int.MaxValue + 1), or a decimal multiplication that exceeds the type's range.
FileNotFoundExceptionA file the program tried to open doesn't exist.File.OpenRead("missing.txt").
KeyNotFoundExceptionA Dictionary<TKey, TValue> indexer lookup found no matching key.dict["missing"] when the key isn't in the dictionary.

A few of these deserve a closer look because the precise behavior is non-obvious.

NullReferenceException is the common one. It happens any time a null reference is dereferenced, and the message is always the same regardless of which variable was null. The runtime doesn't reveal the variable's name, only that something on that line was null. That's why nullable reference types (the ? annotation in modern C#) exist: to push the warning to compile time.

The variable is explicitly typed as string? (a nullable string) and the compiler issues a warning, but the code still runs. At runtime, the call to .ToUpper() on a null reference produces the standard exception.

ArgumentNullException is what well-behaved methods throw when a caller passes null for a parameter that must not be null. It's a subclass of ArgumentException and carries the parameter name in its ParamName property.

ArgumentNullException.ThrowIfNull was added in .NET 6 and is the modern way to validate a parameter. It uses CallerArgumentExpression to capture the parameter name automatically, so the error message names the actual argument the caller passed.

IndexOutOfRangeException and ArgumentOutOfRangeException look similar but come from different places. Arrays use IndexOutOfRangeException. List<T> uses ArgumentOutOfRangeException. The distinction is historical: arrays existed before the BCL standardized on ArgumentOutOfRangeException for collection indexers, and the older behavior remained.

InvalidOperationException is the catch-all for "the object isn't in a state that lets you do this." The classic example is iterating a collection while another piece of code modifies it.

The enumerator detected that the underlying list changed during iteration and threw InvalidOperationException. The enumerator object is still alive; it refuses to keep going because its assumption about the list has been violated.

FormatException and OverflowException both come from parsing. FormatException means the text wasn't a number at all; OverflowException means the text was a valid number but it doesn't fit in the destination type.

DivideByZeroException is mostly an integer (and decimal) phenomenon. Floating-point division by zero in C# doesn't throw; it produces double.PositiveInfinity, double.NegativeInfinity, or double.NaN depending on the operand signs.

KeyNotFoundException is Dictionary<TKey, TValue>'s way of saying the indexer couldn't find the requested key. It's only thrown by the indexer (dict["missing"]). The TryGetValue method returns a bool instead, which is the standard way to handle "maybe present" lookups without paying for an exception.

FileNotFoundException lives in System.IO and is what file APIs throw when they're asked to read a file that doesn't exist. It carries a FileName property with the path it tried to open.

Output (path varies):

The pattern across all of these is the same: a specific failure mode gets a specific exception type, and the type carries the relevant context as typed properties. After seeing the common ones, reading a stack trace usually reveals what went wrong before the message is read.

What Happens When Nothing Catches It

When code throws an exception, control leaves the throwing statement and starts looking for a handler. The runtime walks up the call stack, frame by frame, asking each one "is there a catch for this type?" If a frame has a matching handler, the search stops there and the handler runs. If no frame in the stack has a matching handler, the exception is unhandled, and the runtime is left holding a problem it has no instructions to solve.

The default behavior for an unhandled exception in a normal .NET console application is to:

  1. Print the exception's ToString() representation (the type, the message, and the stack trace) to standard error.
  2. Set the process exit code to a non-zero value.
  3. Terminate the process.

That's a strict response, and it exists for correctness: if an exception escaped without anyone catching it, the program doesn't know what state it's in. Continuing to run on damaged state is worse than stopping, so the runtime stops. The stack trace exists so the developer can find the problem after the fact.

The diagram shows the loop: throw, walk, check, repeat until either a handler matches or the stack runs out. When the stack runs out, the runtime gives up and terminates.

The actual behavior depends on the kind of application:

  • Console application: prints the stack trace to standard error, exits with a non-zero code.
  • ASP.NET Core application: the framework's middleware catches unhandled exceptions in request processing, returns a 500 response, logs the trace, and keeps the process alive. The application doesn't die; the request does.
  • Windows desktop application (WPF, WinForms): the framework offers an AppDomain.UnhandledException event for subscription, but without a handler, the process terminates.
  • Background thread or task: in .NET Core and later, unhandled exceptions on background threads still terminate the process by default, unless caught by a higher-level construct like the Task machinery.

The console case is the foundational one to learn first.

The string "Before" prints because the program reached that line normally. Then Crash() runs, throws, the stack walk leaves both Crash and Main (neither has a handler), and the runtime prints the trace and exits. The string "After" never prints. The line after the failing call is unreachable on this run.

The stack trace lists the frames in reverse: the deepest frame (where the throw happened) is at the top, and the outermost frame (Main) is at the bottom. Reading from top to bottom shows the path the runtime took during the walk. Reading from bottom to top shows the path the program took to reach the throw.

The exit code matters for tooling. CI systems, shell scripts, and process supervisors all key off the exit code. A non-zero exit code says "this run failed." When an unhandled exception terminates a process, the exit code is non-zero by design, so the surrounding infrastructure can detect the failure without parsing the trace.

That program catches anything that escapes Run, logs it, and returns 1. Without the catch, the runtime would still set a non-zero exit code, but the message and format would be the runtime's default. Wrapping Main like this provides control over what users (or scripts) see.

An unhandled exception doesn't just print and exit; it also runs any registered AppDomain.ProcessExit handlers, flushes buffers, and tears down the runtime. For a fast-exiting process this is invisible. For a long-running one, the teardown can take noticeable time, especially with finalizers queued. Crashing isn't free.

The takeaway is simple. Throwing an exception is fine when the failure is exceptional, and the runtime handles it correctly without a catch: terminate cleanly with a useful trace. The opposite extreme, wrapping everything in try-catch (Exception) to keep the process alive, is worse than crashing, because it hides bugs that the operator might otherwise notice. The next several lessons cover when and how to catch exceptions deliberately.

ApplicationException and the Historical Confusion

ApplicationException is a class in System that derives directly from Exception. It exists alongside SystemException and was originally intended as the base class for user-defined exceptions, while SystemException would be the base for runtime-thrown exceptions. The idea: catch ApplicationException to handle anything the application threw, and catch SystemException to handle anything the runtime or BCL threw.

The split never worked. The .NET Framework itself didn't follow the rule: many BCL exceptions derive from Exception directly without going through SystemException. User code rarely followed the rule either, because there was no benefit to inheriting from ApplicationException versus inheriting from Exception directly. The two became indistinguishable in practice.

Microsoft's design guidelines, published years ago, explicitly recommend against using ApplicationException as a base class. The official guidance: derive custom exceptions from Exception directly, or from the closest meaningful built-in exception (ArgumentException, InvalidOperationException, and so on).

ApplicationException still exists in the BCL, and appears in older codebases as the base class for custom exceptions written before the guidance changed. New code shouldn't use it. Reading code that uses it is fine, but don't add to the pile.

Both classes work. Both can be caught by catch (Exception). The recommended one is shorter and matches current guidance.

Inheriting from ApplicationException doesn't break anything, but it spreads a pattern the platform has formally moved away from. New team members will ask "why are we doing this?" and there's no answer except "history." Avoid the discussion by following the current guidance from the start.

The short version: ApplicationException exists for historical reasons. Don't use it for new code. Catch Exception to catch everything, catch a specific type to handle a specific failure, and derive custom exceptions from Exception (or from a more specific type when one fits).