Errors & I/O - Exceptions, Resources & Files
Up to now your programs have run the happy path: the file was there, the number parsed, the index was in range. Real programs spend half their lives off it - disks fill, users type nonsense, networks blink. This phase covers how C# tells you when something went wrong, and how you respond without leaving a mess behind.
If you've read the Go guide, you saw a language where errors are values - a function returns a failure alongside its result, checked on the spot. C# made the opposite bet: failure is a thrown object that interrupts normal flow and travels up the call stack looking for someone willing to handle it. That's the exception, and getting comfortable with it - when to catch, when to throw, how to always clean up - is the whole job.
Exceptions - C#'s error model
What it actually is. An exception is an object (an instance of Exception or a subclass) thrown when something goes wrong. The instant it's thrown, the current method stops dead and the runtime starts unwinding the stack - abandoning the current method, then its caller, then its caller - until it finds a catch block willing to handle it. If nobody catches it, the program crashes and prints a stack trace showing the path the exception took.
📝 Exception - an object describing a failure, thrown at the point of trouble and caught (or not) up the call chain. try wraps risky code; catch handles a failure; finally runs cleanup either way. Stack trace - the printed list of method calls the exception unwound through, newest first.
⚠️ C# exceptions are all unchecked. Unlike Java's "checked exceptions," the compiler never makes you wrap a call in try/catch. Any method can throw anything, and you'll only find out at runtime or by reading its docs - freedom and a footgun in one.
The mental model: a thrown exception is a hot potato, rising until someone catches it or it falls out the top and crashes the program.
A real example.
int[] scores = { 90, 80, 70 };
Console.WriteLine("about to read index 5");
Console.WriteLine(scores[5]); // there is no index 5
Console.WriteLine("this line never runs");
$ dotnet run
about to read index 5
Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.
at Program.<Main>$(String[] args) in /app/Program.cs:line 4
What just happened: scores[5] reached past the end of a three-element array, so the runtime threw an IndexOutOfRangeException. The third WriteLine never ran - the throw aborted the method immediately. With no catch anywhere up the stack, the program printed "Unhandled exception," the type and message, and the stack trace pointing at line 4 - your best clue for where a crash happened.
Now wrap it so the failure is handled instead of fatal:
int[] scores = { 90, 80, 70 };
try
{
Console.WriteLine(scores[5]);
}
catch (IndexOutOfRangeException ex)
{
Console.WriteLine($"oops: {ex.Message}");
}
finally
{
Console.WriteLine("cleanup always runs");
}
Console.WriteLine("program continues normally");
$ dotnet run
oops: Index was outside the bounds of the array.
cleanup always runs
program continues normally
What just happened: the try block threw, and control jumped straight to the matching catch, which read Message and kept going. finally ran next - it runs whether the try succeeded, threw and was caught, or threw something not caught here. Execution flowed past the whole block and the program continued. The exception was contained.
Catching well - be specific, don't swallow
What it actually is. A catch block can name the exception type it handles. Stack several, and the runtime picks the first matching type. The art is catching failures you can actually do something about, and letting everything else keep rising.
💡 Catch specific types, not Exception. A bare catch (Exception ex) grabs everything: the file-not-found you expected, but also out-of-memory, a null-reference bug, a typo you'd want to crash loudly. Swallowing it all hides real bugs behind a calm-looking but quietly broken program. Catch the narrowest type you're prepared to handle; if you can't recover, don't catch - let it propagate (or crash, which is the clean outcome).
A real example.
string input = "not a number";
try
{
int n = int.Parse(input);
Console.WriteLine($"parsed {n}");
}
catch (FormatException ex)
{
Console.WriteLine($"bad input, not a number: {ex.Message}");
}
catch (OverflowException)
{
Console.WriteLine("that number is too big to fit in an int");
}
$ dotnet run
bad input, not a number: The input string 'not a number' was not in a correct format.
What just happened: int.Parse throws a FormatException when the text isn't a number and an OverflowException when it's too large for int. We wrote a separate catch for each, and the runtime matched FormatException and skipped the other. We did not write catch (Exception) - an unrelated bug should crash and show us, not get mistaken for "bad input."
Exception filters with when. Sometimes you want to catch a type only when a condition holds - retry on some HTTP failures but not others. when adds that condition without forcing you to catch, inspect, and re-throw.
try
{
throw new InvalidOperationException("retryable: server busy");
}
catch (InvalidOperationException ex) when (ex.Message.Contains("retryable"))
{
Console.WriteLine("caught a retryable error, will try again");
}
$ dotnet run
caught a retryable error, will try again
What just happened: the when (...) filter ran before the catch body engaged. The message contained "retryable," so the filter returned true and this block handled it. Had it not matched, this catch would have been skipped entirely and the exception kept unwinding, as if the block weren't there. That's the win over catching-then-rethrowing: a declined exception never counts as "handled here," keeping a cleaner stack trace.
Throwing & custom exceptions
What it actually is. You raise an exception yourself with throw new SomeException("message"), when your method is asked to do something it can't sensibly do. Built-in types cover most cases: ArgumentException (a parameter is wrong), ArgumentNullException (a required argument was null), InvalidOperationException (wrong state for this call).
A real example - validate and throw.
decimal Withdraw(decimal balance, decimal amount)
{
if (amount <= 0)
throw new ArgumentException("amount must be positive", nameof(amount));
if (amount > balance)
throw new InvalidOperationException("insufficient funds");
return balance - amount;
}
Console.WriteLine(Withdraw(100m, 30m)); // fine
Console.WriteLine(Withdraw(100m, 500m)); // throws
$ dotnet run
70
Unhandled exception. System.InvalidOperationException: insufficient funds
at Program.<Withdraw>g__Withdraw|0_0(Decimal balance, Decimal amount)
What just happened: the first call passed validation and returned 70. The second asked to withdraw more than the balance - a state this method refuses to handle - so it threw InvalidOperationException. throw immediately stopped Withdraw and handed the failure to the caller. nameof(amount) passes the parameter's name to the exception so the message can say which argument was bad, without hard-coding a string that would rot if you renamed the parameter.
A small custom exception. When the built-ins don't capture your domain, derive your own from Exception - convention names it ...Exception with a constructor that takes a message.
public class InsufficientFundsException : Exception
{
public decimal Shortfall { get; }
public InsufficientFundsException(decimal shortfall)
: base($"short by {shortfall:C}")
{
Shortfall = shortfall;
}
}
decimal Withdraw(decimal balance, decimal amount)
{
if (amount > balance)
throw new InsufficientFundsException(amount - balance);
return balance - amount;
}
try
{
Withdraw(100m, 130m);
}
catch (InsufficientFundsException ex)
{
Console.WriteLine($"declined - {ex.Message} (shortfall {ex.Shortfall})");
}
$ dotnet run
declined - short by $30.00 (shortfall 30)
What just happened: InsufficientFundsException carries a typed Shortfall field, so callers can catch it specifically and read structured data off it - far better than parsing a string message. : base(...) hands a human-readable message up to Exception. A caller can write catch (InsufficientFundsException ex) to handle exactly this case.
💡 Throw vs. return a result. Throw for the exceptional - the genuinely unexpected, "I can't do my job" case. For normal, expected outcomes (a lookup that might miss, a parse that might fail on user input), prefer a non-throwing path: int.TryParse and dictionary.TryGetValue return a bool instead, because "the user typed letters" is a Tuesday, not a catastrophe. Exceptions are relatively expensive and interrupt flow.
using / IDisposable - deterministic cleanup
What it actually is. Some objects hold resources the garbage collector can't tidy up promptly - open files, sockets, database connections. These must be released the moment you're done, not "eventually." C#'s answer is IDisposable: any type holding such a resource implements a Dispose() method that releases it. using guarantees Dispose() runs the instant the variable leaves scope - even if an exception is thrown partway through.
📝 IDisposable - an interface with one method, Dispose(), for releasing unmanaged resources. using - calls Dispose() automatically when its scope ends: "open this, and no matter what happens, close it on the way out."
Why this exists. You could do this by hand with try/finally - open the file, Close() in a finally block so it runs even on failure. using is that pattern compressed into one keyword you can't forget.
A real example.
using (var writer = new StreamWriter("log.txt"))
{
writer.WriteLine("first line");
writer.WriteLine("second line");
} // writer.Dispose() runs HERE - file flushed and closed automatically
Console.WriteLine("file is closed and saved");
What just happened: StreamWriter implements IDisposable because it holds an open file handle. The using block opened the file, wrote to it, and called writer.Dispose() automatically at the closing brace, flushing buffered text to disk and releasing the handle. Even if WriteLine had thrown, Dispose() would still run, so the file would never stay locked open.
Modern C# offers a tidier form, the using declaration - no braces, disposal at the end of the enclosing scope:
void SaveReport()
{
using var writer = new StreamWriter("report.txt");
writer.WriteLine("totals: ...");
// no closing brace block - writer.Dispose() runs when SaveReport() returns
}
What just happened: using var writer = ... is the same guarantee with less nesting - Dispose() fires when writer falls out of scope at the method's end. Use this form when the resource lives the whole method; use braced using (...) { } to release it partway through instead.
File I/O - reading and writing
What it actually is. System.IO is C#'s toolbox for files and streams. For "read/write a whole file," the static File class has one-call helpers; for a large file piece by piece, StreamReader streams it line by line without loading it all into memory.
A real example - the one-call helpers.
using System.IO;
File.WriteAllText("greeting.txt", "hello\nfrom C#");
string whole = File.ReadAllText("greeting.txt");
Console.WriteLine($"--- whole file ---\n{whole}");
string[] lines = File.ReadAllLines("greeting.txt");
Console.WriteLine($"--- line count: {lines.Length} ---");
foreach (string line in lines)
Console.WriteLine($"> {line}");
$ dotnet run
--- whole file ---
hello
from C#
--- line count: 2 ---
> hello
> from C#
What just happened: File.WriteAllText created (or overwrote) the file and wrote the string in one call - opens, writes, closes for you, no handle to dispose. File.ReadAllText slurped the entire contents as one string; File.ReadAllLines split on line breaks into a string[]. Perfect for small files. ⚠️ The catch is in the name: ReadAllText loads the whole file into memory, so a multi-gigabyte log needs streaming instead.
Reading line by line with StreamReader. For a big file, read it as a stream so only one line sits in memory at a time, wrapped in using so the handle always closes.
using System.IO;
File.WriteAllLines("data.txt", new[] { "alpha", "beta", "gamma" });
using var reader = new StreamReader("data.txt");
string? line;
int count = 0;
while ((line = reader.ReadLine()) != null)
{
count++;
Console.WriteLine($"{count}: {line}");
}
$ dotnet run
1: alpha
2: beta
3: gamma
What just happened: StreamReader.ReadLine() returns the next line each call, and null when nothing's left - the loop's exit condition. Only one line is held at a time, working on files far too large for RAM. using var releases the handle when the method ends, even if a read throws. string? line marks it possibly-null, since ReadLine() returns null at end of file (more in Phase 13).
⚠️ NullReferenceException - the error you'll hit most. Calling a method or reading a property on a null reference throws NullReferenceException ("Object reference not set to an instance of an object") - C#'s single most common runtime crash, from a file read returning null, a missed dictionary lookup, an object you forgot to construct. The modern defense is nullable reference types, warning the compiler about possible nulls before you run - covered in Phase 13 and Phase 9. For now: check before you use a value that could be null.
Recap
- Exceptions are C#'s error model - a thrown object aborts the current method and unwinds the stack until a
catchhandles it; uncaught, it crashes with a stack trace.try/catch/finallyis the structure, andfinallyalways runs. - All C# exceptions are unchecked - the compiler never forces you to handle one, so knowing what can throw is on you. Catch specific types you can recover from; never swallow a bare
catch (Exception)and hide real bugs. Usewhenfilters to catch conditionally. - Throw deliberately -
throw new ArgumentException(...)when your method can't do its job; define a custom: Exceptionto carry typed, domain-specific data. Reserve exceptions for the genuinely exceptional; preferTryParse-style methods for expected failures. using/IDisposableguarantee cleanup -using var f = ...;callsDispose()at scope end no matter what, so files, connections, and sockets are always released. It'stry/finallyfor resources, made unforgettable.- File I/O lives in
System.IO-File.ReadAllText/WriteAllText/ReadAllLinesfor whole small files;StreamReader.ReadLine()in ausingfor large files read line by line. - ⚠️
NullReferenceExceptionis the crash you'll meet most - calling into anullreference. Check for null, and lean on nullable reference types (Phase 13) to catch it at compile time.
You can now fail gracefully and clean up after yourself - the difference between a toy and a program people trust. Next we step out of the language and into the toolbox around it: how real C# projects are structured, how to pull in libraries with NuGet, and the commands that build and run it all.
Quick check
Test yourself on the ideas that matter most - how exceptions flow, and why using exists:
[
{
"q": "What happens the moment an exception is thrown and nothing in the current method catches it?",
"choices": [
"The runtime unwinds the stack, abandoning the current method and rising to its caller, looking for a matching catch",
"The method returns its default value and execution continues normally",
"The compiler refuses to build the program until you add a try/catch",
"The exception is silently ignored and the next line runs"
],
"answer": 0,
"explain": "A thrown exception aborts the current method and travels up the call stack, method by method, until a matching catch handles it - or it falls out the top and crashes the program. The compiler never forces you to handle it: C# exceptions are all unchecked."
},
{
"q": "Why is catching `Exception` (the base type) usually a bad idea?",
"choices": [
"It grabs everything - including bugs you'd want to crash loudly - and hides them behind a program that looks fine but is quietly broken",
"It is slower than catching a specific type",
"The compiler emits an error when you catch the base Exception type",
"It only works inside a finally block"
],
"answer": 0,
"explain": "A bare catch (Exception) swallows the failure you expected AND unrelated bugs like null-reference or out-of-memory. Catch the narrowest type you can actually recover from; let everything else keep propagating."
},
{
"q": "What does a `using` statement guarantee about the object it wraps?",
"choices": [
"Its Dispose() method is called when the variable leaves scope, even if an exception is thrown",
"The object can never be set to null",
"The object is loaded entirely into memory before use",
"The object's methods can only be called once"
],
"answer": 0,
"explain": "using is try/finally for resources: it calls Dispose() automatically at the end of scope no matter how you leave it - normal exit or exception. That is how files, sockets, and connections get released promptly and reliably."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. What happens the moment an exception is thrown and nothing in the current method catches it?
2. Why is catching `Exception` (the base type) usually a bad idea?
3. What does a `using` statement guarantee about the object it wraps?