Skip to main content

📝 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 File helper methods
  • Build safe file paths with the Path class
  • Check for existence and handle file-related exceptions
  • Use StreamReader/StreamWriter for larger files
  • Use the using statement 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 File class — 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);
}
MethodDoes
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
graph LR A["using (open resource)"] --> B["Use the stream"] B --> C{"Exception?"} C -->|"No"| D["Dispose automatically"] C -->|"Yes"| D D --> E["Resource released"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style E fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

⚠️ 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:

  1. Create a new project called Notes.
  2. Build the file path with Path.Combine (e.g. notes.txt in the current folder).
  3. On startup, if the file exists, read and print all existing notes; otherwise print "No notes yet."
  4. Prompt the user for a new note and append it (with a newline) so old notes are preserved.
  5. Wrap file operations in try/catch (IOException).
  6. 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 File class reads/writes whole files in one call: ReadAllText, WriteAllText (overwrites!), AppendAllText, ReadAllLines, WriteAllLines, Exists.
  • Build paths with Path.Combine and inspect them with Path.GetFileName/GetExtension — never hand-concatenate.
  • Reading a missing file throws FileNotFoundException; guard with File.Exists and/or try/catch (IOException).
  • Use streams for large files, and always wrap them in using so 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.