📝 Lesson 4.1: Working with Files and Streams
Until now, everything your programs produced vanished when they closed. Files change that. In this lesson you'll read and write files so your data persists — and learn to clean up resources properly.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Read and write whole files with the
Filehelper methods - Build safe file paths with the
Pathclass - Check for existence and handle file-related exceptions
- Use
StreamReader/StreamWriterfor larger files - Use the
usingstatement to release resources automatically
Estimated Time: 60 minutes
Project: Build a tiny notes app that saves to and loads from a file.
In This Lesson
Why Files?
Data stored in variables lives only in memory — when the program ends, it's gone. To persist data between runs, you write it somewhere durable. The simplest option is a file on disk.
📖 Definition
File I/O (input/output): reading data from and writing data to files. .NET's file APIs live in the System.IO namespace.
.NET gives you two levels of tools, and you'll use both:
- The
Fileclass — simple one-call methods for reading/writing an entire file. Perfect for small files. - Streams (
StreamReader/StreamWriter) — read or write a little at a time, ideal for large files.
The File Class
The File class has convenient static methods that handle the whole read or write in one call:
using System.IO; // (modern templates often include this implicitly)
// Write a whole string to a file (creates or overwrites)
File.WriteAllText("greeting.txt", "Hello, file!");
// Read it all back
string content = File.ReadAllText("greeting.txt");
Console.WriteLine(content); // Hello, file!
// Append without overwriting
File.AppendAllText("greeting.txt", "\nA second line.");
For line-based data, use the array-oriented versions — they pair naturally with collections and LINQ:
string[] lines = { "Ada", "Grace", "Alan" };
File.WriteAllLines("names.txt", lines);
string[] readBack = File.ReadAllLines("names.txt");
Console.WriteLine($"{readBack.Length} names"); // 3 names
foreach (string name in readBack)
{
Console.WriteLine(name);
}
| Method | Does |
|---|---|
File.WriteAllText(path, text) | Write a string (overwrites) |
File.ReadAllText(path) | Read the whole file as one string |
File.AppendAllText(path, text) | Add to the end |
File.WriteAllLines(path, lines) | Write a collection of lines |
File.ReadAllLines(path) | Read into a string[] |
File.Exists(path) | Returns true if the file exists |
⚠️ Write overwrites without asking
WriteAllText and WriteAllLines replace the file's entire contents if it already exists — no confirmation. Use AppendAllText when you mean to add rather than replace.
Building Paths Safely
File paths differ across operating systems (Windows uses \, macOS/Linux use /). Never hand-build paths with string concatenation — use the Path class, which does it correctly everywhere:
// ❌ Fragile — wrong separator on some systems
string bad = "data" + "\\" + "notes.txt";
// ✅ Correct on every OS
string good = Path.Combine("data", "notes.txt");
Console.WriteLine(Path.GetFileName(good)); // notes.txt
Console.WriteLine(Path.GetExtension(good)); // .txt
Console.WriteLine(Path.GetFileNameWithoutExtension(good)); // notes
💡 Special folders
To find standard locations (like the user's documents folder) portably, combine Environment.GetFolderPath with Path.Combine:
string docs = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
string filePath = Path.Combine(docs, "notes.txt");
Existence & Exceptions
Reading a file that doesn't exist throws a FileNotFoundException (from Lesson 1.1). Guard against it — either check first, or wrap the read in try/catch:
string path = "notes.txt";
// Option 1: check before reading
if (File.Exists(path))
{
string text = File.ReadAllText(path);
Console.WriteLine(text);
}
else
{
Console.WriteLine("No notes file yet.");
}
// Option 2: handle the exception
try
{
string text = File.ReadAllText(path);
Console.WriteLine(text);
}
catch (FileNotFoundException)
{
Console.WriteLine("File not found.");
}
catch (IOException ex)
{
Console.WriteLine($"A file error occurred: {ex.Message}");
}
✅ Both — belt and braces
Checking with File.Exists handles the common "not there yet" case cleanly. But files can vanish or lock between the check and the read, and other I/O problems (permissions, disk full) still happen — so wrapping file work in try/catch (IOException) is good practice for anything important.
Streams and using
The File.ReadAllText helpers load the entire file into memory at once — fine for small files, wasteful (or impossible) for huge ones. A stream reads or writes a bit at a time, so memory use stays low regardless of file size.
// Read a large file line by line with a StreamReader
using (StreamReader reader = new StreamReader("big-log.txt"))
{
string? line;
while ((line = reader.ReadLine()) != null) // null means end of file
{
Console.WriteLine(line);
}
} // reader is automatically closed and disposed here
Notice the using statement. A StreamReader holds an operating-system file handle that must be released. using guarantees that happens — even if an exception is thrown inside the block:
💡 using and IDisposable
Any type that implements IDisposable (the interface from the intro course) can be wrapped in using. When the block ends — normally or via an exception — its Dispose() runs automatically, releasing the resource. This is the clean alternative to a manual finally we mentioned back in Lesson 1.1.
Modern C# also offers a tidier using declaration — no braces; the resource is disposed at the end of the enclosing scope:
void WriteReport()
{
using StreamWriter writer = new StreamWriter("report.txt");
writer.WriteLine("Line 1");
writer.WriteLine("Line 2");
} // writer disposed here, at the end of the method
⚠️ Always dispose file resources
If you open a stream and forget to close/dispose it, the file may stay locked, changes may not be flushed to disk, and you can run out of handles. Always use using with streams — never rely on it "probably being fine."
Exercise & Quiz
🏋️ Exercise: A Persistent Notes App
Objective: Save and load data across program runs using files.
Instructions:
- Create a new project called
Notes. - Build the file path with
Path.Combine(e.g.notes.txtin the current folder). - On startup, if the file exists, read and print all existing notes; otherwise print "No notes yet."
- Prompt the user for a new note and append it (with a newline) so old notes are preserved.
- Wrap file operations in
try/catch (IOException). - Bonus: Run the program a few times and confirm notes accumulate.
Starter Code:
string path = Path.Combine(".", "notes.txt");
// TODO: if the file exists, read and print existing notes
Console.Write("Enter a new note: ");
string note = Console.ReadLine() ?? "";
// TODO: append the note (+ newline) to the file, inside try/catch
💡 Hint
Use File.Exists(path) then File.ReadAllText(path). Append with File.AppendAllText(path, note + Environment.NewLine);. Environment.NewLine is the correct newline for the OS.
✅ Solution
string path = Path.Combine(".", "notes.txt");
try
{
if (File.Exists(path))
{
Console.WriteLine("Your notes so far:");
Console.WriteLine(File.ReadAllText(path));
}
else
{
Console.WriteLine("No notes yet.");
}
Console.Write("Enter a new note: ");
string note = Console.ReadLine() ?? "";
File.AppendAllText(path, note + Environment.NewLine);
Console.WriteLine("Note saved!");
}
catch (IOException ex)
{
Console.WriteLine($"Could not access the notes file: {ex.Message}");
}
After running twice (entering "Buy milk", then "Walk dog"), the third run prints:
Your notes so far:
Buy milk
Walk dog
Enter a new note: _
🎯 Quick Quiz
Question 1: What does File.WriteAllText do if the file already exists?
Question 2: Why use Path.Combine instead of concatenating path strings yourself?
Question 3: What does a using statement guarantee for a StreamReader?
Summary
🎉 Key Takeaways
- Files let data persist between runs; file APIs live in
System.IO. - The
Fileclass reads/writes whole files in one call:ReadAllText,WriteAllText(overwrites!),AppendAllText,ReadAllLines,WriteAllLines,Exists. - Build paths with
Path.Combineand inspect them withPath.GetFileName/GetExtension— never hand-concatenate. - Reading a missing file throws
FileNotFoundException; guard withFile.Existsand/ortry/catch (IOException). - Use streams for large files, and always wrap them in
usingso they're disposed (released) automatically.
📚 Additional Resources
🚀 What's Next?
You can save and load text — but real apps store structured data. In Lesson 4.2: JSON Serialization, you'll convert your objects (records and classes) to and from JSON, so you can save and reload them whole.
🎉 Your data now persists!
Programs that remember across runs feel real. Next: saving structured objects with JSON.