The moment your program touches the disk, it's touching something it doesn't fully control — and that changes how you have to write code.
Everything you've written so far has lived entirely in memory — variables, objects, collections — all of it gone the instant the program ends. Real applications need to outlive that: a log service that writes lines to a file, a report generator that saves a CSV, an import job that reads orders from a file another system dropped on disk.
The moment you read or write a file, you're no longer just talking to your own program's memory — you're talking to the operating system, a physical (or virtual) disk, a filesystem with its own rules about permissions, existence, and availability. Any of that can go wrong in ways that pure in-memory code never has to worry about: the file might not exist, another process might have it locked, the disk might be full, you might lack permission to write there.
In this lesson, you'll learn how to read and write files and directories using the File and Directory classes, and why file I/O code needs to actively plan for failure in a way that in-memory code doesn't.
.NET gives you two static helper classes, right in the base class library, for working with the filesystem: System.IO.File for individual files (reading, writing, checking existence, deleting), and System.IO.Directory for folders (creating, listing contents, deleting). They're the simplest, most direct way to do everyday file work — no manual setup required.
File and Directory are static classes in the System.IO namespace that wrap lower-level operating-system file APIs. Each method is a self-contained operation — open, read/write everything, close — that internally manages the underlying resources for you. For finer control over reading and writing incrementally, you drop down to streams (the subject of the next lesson) — but for a huge share of everyday file tasks, File and Directory are all you need.
| Method | What it does |
|---|---|
File.Exists(path) | Returns true/false — does a file exist at this path? |
File.ReadAllText(path) | Reads the entire file into one string. |
File.ReadAllTextAsync(path) | Same, asynchronously — preferred for I/O-bound work. |
File.ReadAllLines(path) | Reads the file into a string[], one element per line. |
File.WriteAllText(path, text) | Writes a string to a file, overwriting it if it already exists. |
File.AppendAllText(path, text) | Adds text to the end of a file without erasing what's already there. |
File.Copy(src, dest) | Copies a file. |
File.Delete(path) | Deletes a file (does nothing if it doesn't exist). |
Directory.Exists(path) | Returns true/false — does a directory exist? |
Directory.CreateDirectory(path) | Creates a directory, including any missing parent folders. |
Directory.GetFiles(path) | Lists the files in a directory. |
Directory.Delete(path, recursive) | Deletes a directory, optionally including everything inside it. |
Variables and objects vanish the instant a process ends. But an order-import job needs to read data that arrived before it started running. A logging service needs its output to survive after the program that wrote it has stopped. A report generator needs to hand a finished file to someone else entirely. None of this is possible with in-memory state alone.
Applications need a standard, reliable way to persist data to durable storage, and to read data that other processes (or earlier runs of the same process) have already written there — without every developer having to hand-roll calls to raw operating system file APIs.
System.IO.File and System.IO.Directory give you simple, high-level, cross-platform methods that hide the OS-specific complexity of opening handles, managing buffers, and closing resources correctly — for the common case of "just read this whole file" or "just write this whole file."
File I/O sits at the boundary between your program and the outside world — and that boundary is exactly where things can go wrong that are entirely outside your program's control:
This is exactly why file operations pair so naturally with what you just learned about exceptions — File.ReadAllText throws FileNotFoundException if the file is missing, UnauthorizedAccessException if you lack permission, and IOException if the file is locked by another process. None of these are bugs in your code — they're the filesystem telling you something you couldn't have known in advance.
if (!File.Exists(path))
{
Console.WriteLine("No such file — nothing to import today.");
return;
}
try
{
string contents = await File.ReadAllTextAsync(path);
ProcessContents(contents);
}
Exists, the file could vanish between the check and the read — another process could delete it in that gap. The try/catch is still necessary.catch (FileNotFoundException)
{
Console.WriteLine("File disappeared before we could read it.");
}
catch (UnauthorizedAccessException)
{
Console.WriteLine("No permission to read this file.");
}
catch (IOException ex)
{
Console.WriteLine($"The file is in use or the disk had a problem: {ex.Message}");
}
File I/O involves waiting on the disk (or, for a network share, waiting on the network too) — time your program's thread doesn't need to spend blocked and idle. File.ReadAllTextAsync and File.WriteAllTextAsync let the thread go do other work while the operating system handles the actual disk activity, which is why they're the preferred choice in modern .NET code — especially in server applications handling many requests at once. (The full mechanics of async/await are their own topic later in the course; for now, just know that reaching for the ...Async version of a file method is the idiomatic default.)
Writing a note to disk, then reading it back:
string path = "notes.txt";
// ─── Write ───
await File.WriteAllTextAsync(path, "Remember to review the Q3 budget.");
// ─── Check and read ───
if (File.Exists(path))
{
string contents = await File.ReadAllTextAsync(path);
Console.WriteLine(contents); // Remember to review the Q3 budget.
}
// ─── Append without overwriting ───
await File.AppendAllTextAsync(path, "\nAlso: follow up with the vendor.");
string updated = await File.ReadAllTextAsync(path);
Console.WriteLine(updated);
// Remember to review the Q3 budget.
// Also: follow up with the vendor.
// ─── Clean up ───
File.Delete(path);
Console.WriteLine(File.Exists(path)); // FalseEach call — WriteAllTextAsync, ReadAllTextAsync, AppendAllTextAsync, Delete — is a complete operation in itself: it opens the file, does the work, and closes it again, all in one call. You never have to remember to manually close anything with these particular methods.
string reportsFolder = "reports";
// Creates the directory if it doesn't already exist (and any missing parents)
Directory.CreateDirectory(reportsFolder);
// List every .csv file currently in that folder
string[] csvFiles = Directory.GetFiles(reportsFolder, "*.csv");
foreach (var file in csvFiles)
Console.WriteLine(Path.GetFileName(file));
// Remove the whole folder, including anything left inside it
if (Directory.Exists(reportsFolder))
Directory.Delete(reportsFolder, recursive: true);A background job that imports orders from CSV files dropped into an "incoming" folder, moving each one to "processed" or "failed" once handled — a realistic combination of directory listing, file reading, custom exceptions, and cleanup:
public class OrderImportJob
{
private readonly string _incomingFolder = "orders/incoming";
private readonly string _processedFolder = "orders/processed";
private readonly string _failedFolder = "orders/failed";
public async Task RunAsync()
{
Directory.CreateDirectory(_processedFolder);
Directory.CreateDirectory(_failedFolder);
if (!Directory.Exists(_incomingFolder))
{
Console.WriteLine("No incoming folder yet — nothing to do.");
return;
}
foreach (var filePath in Directory.GetFiles(_incomingFolder, "*.csv"))
{
string fileName = Path.GetFileName(filePath);
try
{
string content = await File.ReadAllTextAsync(filePath);
var orders = ParseOrders(content);
foreach (var order in orders)
SaveToDatabase(order);
File.Move(filePath, Path.Combine(_processedFolder, fileName));
Console.WriteLine($"Imported {fileName} ({orders.Count} orders).");
}
catch (Exception ex)
{
// Don't let one bad file stop the whole batch — quarantine it and move on
File.Move(filePath, Path.Combine(_failedFolder, fileName), overwrite: true);
Console.WriteLine($"Failed to import {fileName}: {ex.Message}");
}
}
}
}Notice this mirrors the per-item error containment pattern from the previous lessons — one malformed CSV file gets quarantined into the "failed" folder instead of halting the entire batch. That's the combination this whole module is building toward: exceptions to signal what went wrong, file operations to actually do the work, and disciplined error handling to keep one bad input from taking down everything else.
In-memory variables are like notes on your own private desk — nobody else can touch them, and they're always exactly as you left them. A file on disk is more like a shared filing cabinet in a busy office: other people (other processes) can open a drawer at the same time you do, someone might have locked a drawer, a folder might have been moved or thrown out entirely since you last checked, and the cabinet itself might occasionally break down.
That's exactly why file code needs defensive handling that pure in-memory code doesn't — you're not the only one with access, and the state of things can change between the moment you check and the moment you act.
This is one of the most common file-handling mistakes. There's a real gap in time between the Exists check and the actual read — another process can delete the file, or a permissions change can take effect, in that exact window. This is sometimes called a "time-of-check to time-of-use" (TOCTOU) gap. Exists is useful for expected cases ("no orders today is normal"), but it's never a substitute for exception handling around the actual operation.
File.ReadAllText reads the entire file into memory in one call — perfect for typical config files, small reports, and CSV imports. For very large files, or when you need to process data as it arrives rather than waiting for the whole file, you use a Stream instead (the next lesson) to read or write incrementally without loading everything into memory at once.
Wrong:
string content = File.ReadAllText(userSuppliedPath); // no error handling at all
ProcessContents(content);Correct: wrap it, and catch the specific failures you expect.
try
{
string content = await File.ReadAllTextAsync(userSuppliedPath);
ProcessContents(content);
}
catch (FileNotFoundException)
{
Console.WriteLine("That file doesn't exist.");
}
catch (UnauthorizedAccessException)
{
Console.WriteLine("You don't have permission to read that file.");
} Calling File.WriteAllText repeatedly in a logging loop — each call erases everything written before it. Use File.AppendAllText (or, for high-frequency writes, a StreamWriter kept open across the whole session, covered in the next lesson) when you actually want to add to a file rather than replace it.
A blanket catch (Exception ex) around a file read hides whether the problem was a missing file, a permissions issue, or a locked file — three very different problems that usually call for three different responses. Catch the specific types you know how to react to.
Exists handles the expected case; a try/catch handles everything you couldn't predict....Async methods for I/O-bound file work.System.IO.File and System.IO.Directory are static classes with simple, self-contained methods for everyday file and folder work.Exists is not a substitute for a try/catch — the file's state can change between the check and the operation.ReadAllTextAsync, WriteAllTextAsync) for I/O-bound file work.FileNotFoundException, UnauthorizedAccessException, IOException) so your handling matches the actual problem.You've seen how File and Directory work, and why file operations need real error handling. Let's make sure it's clear.
1. Why does reading a file typically require more defensive error handling than reading from an in-memory List<T>?
Correct: B
Why B is correct: Unlike an in-memory collection that only your code can touch, a file lives on shared, external storage. Other processes can delete, lock, or move it; permissions can change; the disk itself can run out of space. None of that is under your program's control, so file code has to anticipate failure in ways in-memory code doesn't need to.
Why A is incorrect: File.ReadAllText and its Async counterpart are current, idiomatic .NET APIs — not deprecated.
Why C is incorrect: In-memory collections can throw too (e.g., an out-of-range index) — the point isn't that files are the only source of exceptions, but that file operations face an entire category of external, unpredictable failure that in-memory code doesn't.
Why D is incorrect: Speed isn't the reason for the extra error handling — reliability in the face of external, uncontrolled state is.
Reinforcement: File I/O crosses a boundary into shared, external state — that's exactly why it needs deliberate exception handling.
2. You write: if (File.Exists(path)) { var text = File.ReadAllText(path); } with no try/catch. What's the risk in this code?
Correct: B
Why B is correct: This is the "time-of-check to time-of-use" gap discussed in the lesson — a real amount of time passes between the check and the actual read, and the file's state on disk can change in that window because other processes have access too. Without a try/catch, an exception here would go completely unhandled.
Why A is incorrect: File.Exists correctly returns true when the file is present — nothing about this code prevents the block from running under normal conditions.
Why C is incorrect: This code compiles and runs fine under normal conditions — the issue is a runtime reliability gap, not a compile error.
Why D is incorrect: File.Exists is a read-only check — it never creates, modifies, or deletes anything.
Reinforcement: Exists handles the expected case, but only a try/catch around the actual operation protects against everything that can change in between.
3. Which method call would erase the existing contents of "log.txt" if it already has content, rather than adding to it?
Correct: B
Why B is correct: File.WriteAllText replaces the entire contents of the file with the new text every time it's called — exactly the mistake described in the lesson when someone means to keep adding log lines over time.
Why A is incorrect: AppendAllText is specifically designed to add to the end of the file without touching existing content — it's the correct choice for this scenario, not the mistake.
Why C is incorrect: Directory.CreateDirectory works on folders, not files — calling it with a file-like name would attempt to create a directory, an unrelated and likely error-prone operation.
Why D is incorrect: Exists is a read-only check — it never modifies file contents at all.
Reinforcement: WriteAllText always overwrites; AppendAllText always adds. Pick based on whether you want to replace or accumulate.
4. In the order-import example, why does the code move a failed CSV file into a separate "failed" folder inside a catch block, instead of just letting the exception propagate and stop the whole job?
Correct: B
Why B is correct: This is the per-item error containment pattern from earlier lessons applied to files: catching the exception for just this one file lets the loop move on to the next file rather than aborting the entire batch, and quarantining the bad file preserves it for someone to investigate later.
Why A is incorrect: Directory.GetFiles just returns a list of paths — it has no requirement about what you do with each one afterward.
Why C is incorrect: File.Move only relocates a file on disk — it has no awareness of, or effect on, the file's contents or formatting.
Why D is incorrect: The loop would compile fine without a try/catch — the catch block is a deliberate design choice for resilience, not a compiler requirement.
Reinforcement: Containing failure to the smallest reasonable scope — one file, one row, one item — keeps a batch operation resilient instead of all-or-nothing.
5. You need to process a 20 GB log file line by line without loading the whole thing into memory at once. Is File.ReadAllText a good fit here?
Correct: B
Why B is correct: ReadAllText reads the entire file into a single in-memory string in one call — for a 20 GB file, that's rarely practical. This is exactly the boundary the lesson draws: File/Directory are great for whole small-to-medium files, but large or incremental data calls for Streams, covered next.
Why A is incorrect: It's the opposite — ReadAllText is best suited to small-to-moderate files that comfortably fit in memory.
Why C is incorrect: There's no automatic size-based switch to incremental reading — ReadAllText always loads the full content at once, regardless of file size.
Why D is incorrect: ReadAllText works with any text file, not just CSVs — file extension isn't the deciding factor here at all.
Reinforcement: Whole-file methods are for content that reasonably fits in memory; very large or incremental data needs a Stream.
You can now read, write, and organize files and directories with real error handling in mind. Next up: the Path class — because building file paths by hand is a surprisingly common source of bugs.
dotnetmadeeasy.com — Learn C# and .NET, the right way.