So far, all of BiblioTech's state — the catalog, the members, the loan history — lives only in the program's memory: as soon as the process ends, everything disappears with it. This lesson opens Module 5 (Working with Data) by tackling exactly that problem the most direct way possible: reading and writing files on disk with the System.IO namespace. You'll learn to check whether a file exists, to read and write it all at once or line by line, to release the resources you open correctly, and to handle the errors specific to I/O (input/output). By the end of the lesson, Library will be able to save and recover its complete catalog in a plain text file — the first, very basic, form of real persistence in the whole course.

Contents

  1. The System.IO namespace and the two levels of working with files
  2. Checking whether a file exists: File.Exists
  3. Everything at once: File.ReadAllText/File.WriteAllText and File.ReadAllLines/File.WriteAllLines
  4. Large files: StreamReader and StreamWriter
  5. using and IDisposable: releasing resources correctly
  6. Handling I/O exceptions: IOException and FileNotFoundException
  7. Persisting BiblioTech's catalog in plain text

  1. The System.IO namespace and the two levels of working with files

Everything related to files and directories in .NET lives in the System.IO namespace. Inside it, two levels of work coexist, designed for different needs:

Level Main classes When to use it
High level, "all at once" File (static methods) Small or medium files that fit comfortably in memory
Low level, "bit by bit" StreamReader, StreamWriter Large files, or when you want to process line by line without loading the whole thing

Both levels end up using the same mechanism underneath (a Stream representing the flow of bytes to or from disk), but File saves you the manual stream management when the file's size isn't a problem. This lesson introduces File first, as the simplest entry point, and then StreamReader/StreamWriter for when that approach isn't enough.

  1. Checking whether a file exists: File.Exists

Before reading a file, it's worth checking that it exists; before writing it, that's not always necessary (File.WriteAllText creates it if it doesn't exist), but it's still good practice before any read:

string path = "catalog.txt";

if (File.Exists(path))
{
    Console.WriteLine("The catalog file already exists.");
}
else
{
    Console.WriteLine("No catalog has been saved yet.");
}

File.Exists(path) returns true/false without throwing any exception, even if the path has an invalid format or the directory doesn't exist (in those cases it simply returns false). It's the safe way to anticipate a missing file, instead of trying to read it directly and catching the exception that would cause.

  1. Everything at once: File.ReadAllText/File.WriteAllText and File.ReadAllLines/File.WriteAllLines

The static File class offers four ready-to-use methods for working with a file's entire content:

Method Reads/writes Returns/receives
File.WriteAllText(path, content) Writes A single string (creates or overwrites the entire file)
File.ReadAllText(path) Reads A single string with all the content
File.WriteAllLines(path, lines) Writes An IEnumerable<string>, one line per element
File.ReadAllLines(path) Reads A string[], one slot per line of the file
// Write all the content at once
File.WriteAllText("greeting.txt", "Welcome to BiblioTech");

// Read it back, also at once
string content = File.ReadAllText("greeting.txt");
Console.WriteLine(content); // "Welcome to BiblioTech"

// Write several lines from an array or a list
string[] titles = { "Hopscotch", "Ficciones", "The Aleph" };
File.WriteAllLines("titles.txt", titles);

// Read them back, one per array position
string[] readTitles = File.ReadAllLines("titles.txt");
foreach (string title in readTitles)
{
    Console.WriteLine(title);
}

Both write methods overwrite the file if it already existed (they don't append content at the end); if you need to add without erasing what was there, File.AppendAllText and File.AppendAllLines exist, with the same usage. These four methods are the default choice whenever the file is of a reasonable size: internally they open the file, read or write its entire content, and close it automatically for you, with nothing else to manage.

  1. Large files: StreamReader and StreamWriter

When a file is too large to load entirely into memory at once (think of a .csv with millions of rows), or when you want to start processing its lines before it finishes being read completely, File.ReadAllText/File.ReadAllLines stop being adequate: they load the whole file into memory before returning control. StreamReader and StreamWriter solve this by working line by line, without ever needing the complete file in memory:

// Write line by line with StreamWriter
StreamWriter writer = new StreamWriter("large_catalog.txt");
writer.WriteLine("Hopscotch;Julio Cortazar");
writer.WriteLine("Ficciones;Jorge Luis Borges");
writer.Close(); // releases the file; without this, the data might never actually get written

// Read line by line with StreamReader
StreamReader reader = new StreamReader("large_catalog.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
    Console.WriteLine($"Line read: {line}");
}
reader.Close();

ReadLine() returns null when it reaches the end of the file, which lets it be used directly as the condition of a while: the loop repeats while there are lines, and ends only when ReadLine() returns null. This pattern (while ((line = reader.ReadLine()) != null)) is common enough in C# that it's worth memorizing as-is.

The previous example has, on purpose, a problem: if an exception occurred between opening the file and the Close(), the file would stay open forever (a resource leak). The next section fixes exactly that.

  1. using and IDisposable: releasing resources correctly

StreamReader and StreamWriter (just like SqliteConnection, which you'll see in the Database Connectivity lesson) represent a resource external to the program — a file opened by the operating system — that must be released explicitly once you're done using it. They all implement the IDisposable interface (recall IDisposable from the Constructors and Destructors lesson, Module 3), whose Dispose() method releases that resource.

Calling Dispose() (or its equivalent Close()) manually, as in the previous section, works, but it forces you to remember to do it on every exit path of the method, including those that go through an exception. The using statement automates this completely:

using (StreamWriter writer = new StreamWriter("large_catalog.txt"))
{
    writer.WriteLine("Hopscotch;Julio Cortazar");
    writer.WriteLine("Ficciones;Jorge Luis Borges");
} // writer.Dispose() is called automatically here, even if something throws an exception inside the block

using (StreamReader reader = new StreamReader("large_catalog.txt"))
{
    string? line;
    while ((line = reader.ReadLine()) != null)
    {
        Console.WriteLine($"Line read: {line}");
    }
} // same: reader.Dispose() is always called when leaving the block

Since C# 8 there's also the using declaration, with no braces of its own, which releases the resource at the end of the block that contains it (the method, or the { } block where it was declared):

using StreamReader reader = new StreamReader("large_catalog.txt");
string? line;
while ((line = reader.ReadLine()) != null)
{
    Console.WriteLine(line);
}
// reader.Dispose() is called automatically here, at the end of the method (or the containing block)
Form Syntax When the resource is released
using (block) using (var x = ...) { ... } At the end of the { } braces
using (declaration) using var x = ...; At the end of the method or containing block
Manual (Close()/Dispose()) Explicit call Whenever the programmer remembers to call it — fragile, avoid it

Practical rule for the rest of the course: any object that implements IDisposable (files, database connections, HttpClient...) is declared with using, without exception, except in very specific cases outside the scope of this course.

  1. Handling I/O exceptions: IOException and FileNotFoundException

Any I/O operation can fail for reasons outside the program's control: the file doesn't exist, the disk is full, another process has it locked, there aren't enough permissions... All these situations manifest as exceptions, handled with try/catch, exactly as in the Exception Handling lesson (Module 2):

Exception When it's thrown
FileNotFoundException Trying to open a nonexistent file for reading
DirectoryNotFoundException The directory in the given path doesn't exist
UnauthorizedAccessException Not enough permissions to read or write that path
IOException Base class for more general I/O errors (disk full, file locked by another process...)

FileNotFoundException, DirectoryNotFoundException, and UnauthorizedAccessException in fact all inherit from IOException, so a single catch (IOException ex) catches any of them if you don't need to tell them apart; if you do need to, put the more specific catch blocks first, as you learned in the exceptions lesson:

try
{
    string content = File.ReadAllText("catalog.txt");
    Console.WriteLine(content);
}
catch (FileNotFoundException)
{
    Console.WriteLine("No catalog has been saved yet.");
}
catch (IOException ex)
{
    Console.WriteLine($"I/O error while reading the catalog: {ex.Message}");
}

In practice, checking File.Exists beforehand avoids most FileNotFoundException cases; the rest of IOException's subtypes are worth catching whenever the program depends on the operation succeeding.

  1. Persisting BiblioTech's catalog in plain text

With everything above, Library gains two new methods for saving and recovering its catalog (Catalog, from the Collections lesson) in a plain text file: one line per item, with fields separated by ;. Since the catalog mixes Book and Magazine (recall the Inheritance lesson), each line starts with an extra field that identifies the type:

class Library
{
    // ... Catalog, Members, Loans, _membersById, LoanRegistered unchanged ...
    // ... AddItem, AddMember, FindMemberById, RegisterLoan, LendBookAsync unchanged ...

    public void SaveCatalogText(string path)
    {
        using StreamWriter writer = new StreamWriter(path);

        foreach (LibraryItem item in Catalog)
        {
            if (item is Book book)
            {
                writer.WriteLine($"BOOK;{book.Title};{book.Author};{book.Isbn};{book.Available}");
            }
            else if (item is Magazine magazine)
            {
                writer.WriteLine($"MAGAZINE;{magazine.Title};{magazine.Author};{magazine.IssueNumber};{magazine.Available}");
            }
        }
    }

    public void LoadCatalogText(string path)
    {
        if (!File.Exists(path))
        {
            throw new FileNotFoundException($"Catalog file not found: {path}");
        }

        Catalog.Clear();

        using StreamReader reader = new StreamReader(path);
        string? line;
        while ((line = reader.ReadLine()) != null)
        {
            string[] fields = line.Split(';');
            string type = fields[0];
            string title = fields[1];
            string author = fields[2];
            bool available = bool.Parse(fields[4]);

            LibraryItem item = type switch
            {
                "BOOK" => new Book(title, author, fields[3]),
                "MAGAZINE" => new Magazine(title, author, int.Parse(fields[3])),
                _ => throw new InvalidOperationException($"Unknown item type: {type}")
            };

            if (!available)
            {
                item.Lend(); // leaves the item marked as unavailable, just as it was when saved
            }

            AddItem(item);
        }
    }
}

A few details of this manual parsing deserve an explanation:

  • line.Split(';') (recalling Arrays and Strings, Module 1) splits each line into a string[] using ; as the separator; the field order must match exactly between SaveCatalogText (when writing) and LoadCatalogText (when reading).
  • The switch expression (Pattern Matching, Module 4) rebuilds the concrete type (Book or Magazine) from the first field, reusing their constructors exactly as defined in the Inheritance lesson.
  • LoadCatalogText reuses AddItem, already present in Library, instead of calling Catalog.Add(...) directly: the logic for adding an item stays in a single place.
  • This format is deliberately fragile: if a title contained a ;, Split would produce more fields than expected and the parsing would fail. That's exactly the limitation motivating the next lesson, Serialization, with a format (JSON) that doesn't have this problem.
flowchart LR
    A[Library.Catalog in memory] -->|SaveCatalogText| B[catalog.txt]
    B -->|LoadCatalogText| C[Library.Catalog rebuilt]

Common Mistakes and Tips

  • Forgetting using on a StreamReader/StreamWriter: without it, the file can end up locked for other processes, or the written data may never actually reach disk if the process ends before Close()/Dispose() is called explicitly. Always use using, without exception, for any type that implements IDisposable.
  • Reading a file without checking File.Exists first: trying File.ReadAllText on a nonexistent file throws FileNotFoundException; check beforehand, or catch the exception if you prefer a "try and catch" approach instead of "check first."
  • Confusing File.WriteAllText/WriteAllLines with "append to the end": both overwrite the entire file if it already existed; use File.AppendAllText/File.AppendAllLines if the intent is to add content without losing what was there before.
  • A separator that can appear inside the data itself: the ; in this plain text format isn't designed for titles that already contain a ;; in a real case you'd have to escape the separator or choose a more robust format (the problem the next lesson solves).
  • Tip: for small or medium files (the vast majority of cases in a typical application), File.ReadAllText/WriteAllText/ReadAllLines/WriteAllLines are simpler and less error-prone than managing a StreamReader/StreamWriter by hand; save those for genuinely large files or for processing line by line without loading the whole thing.

Exercises

  1. Write a code snippet that uses File.WriteAllLines to save three book titles into a titles.txt file, and then File.ReadAllLines to read them back and display them on the console, one per line.

  2. Write a method void ShowFileIfExists(string path) that checks with File.Exists whether the file exists: if it does, read it with File.ReadAllText and display it on the console inside a try/catch that catches IOException; if it doesn't, display a message saying so, without trying to read it.

  3. Build SaveCatalogText and LoadCatalogText on Library exactly as presented in this lesson. Create a Library, add two books and a magazine, save the catalog to catalog.txt, create a second, empty Library instance, load the file into it with LoadCatalogText, and check that Catalog.Count matches between both instances.

Solutions

string[] titles = { "Hopscotch", "Ficciones", "The Aleph" };
File.WriteAllLines("titles.txt", titles);

string[] readTitles = File.ReadAllLines("titles.txt");
foreach (string title in readTitles)
{
    Console.WriteLine(title);
}
void ShowFileIfExists(string path)
{
    if (!File.Exists(path))
    {
        Console.WriteLine($"The file '{path}' does not exist.");
        return;
    }

    try
    {
        string content = File.ReadAllText(path);
        Console.WriteLine(content);
    }
    catch (IOException ex)
    {
        Console.WriteLine($"I/O error while reading the file: {ex.Message}");
    }
}
Library source = new Library();
source.AddItem(new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4"));
source.AddItem(new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1"));
source.AddItem(new Magazine("National Geographic", "Various authors", 302));

source.SaveCatalogText("catalog.txt");

Library destination = new Library();
destination.LoadCatalogText("catalog.txt");

Console.WriteLine(source.Catalog.Count);      // 3
Console.WriteLine(destination.Catalog.Count); // 3

Conclusion

In this lesson you've learned to work with files using System.IO: checking whether they exist with File.Exists, reading and writing all at once with File.ReadAllText/WriteAllText and ReadAllLines/WriteAllLines, processing large files line by line with StreamReader/StreamWriter, releasing resources correctly with using, and handling the exceptions specific to I/O. Library can now save and recover its complete catalog across runs, with SaveCatalogText and LoadCatalogText.

The plain text format with ; as a separator, however, is fragile: it doesn't handle titles with special characters well, it forces error-prone manual parsing, and it doesn't scale well to more complex data structures (like loans, which link a book to a member). The next lesson, Serialization, replaces this manual parsing with System.Text.Json, a standard format that solves these problems at the root and that will reuse, precisely, the files you already know how to read and write thanks to this lesson.

© Copyright 2026. All rights reserved