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
- The
System.IOnamespace and the two levels of working with files - Checking whether a file exists:
File.Exists - Everything at once:
File.ReadAllText/File.WriteAllTextandFile.ReadAllLines/File.WriteAllLines - Large files:
StreamReaderandStreamWriter usingandIDisposable: releasing resources correctly- Handling I/O exceptions:
IOExceptionandFileNotFoundException - Persisting BiblioTech's catalog in plain text
- The
System.IO namespace and the two levels of working with files
System.IO namespace and the two levels of working with filesEverything 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.
- Checking whether a file exists:
File.Exists
File.ExistsBefore 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.
- Everything at once:
File.ReadAllText/File.WriteAllText and File.ReadAllLines/File.WriteAllLines
File.ReadAllText/File.WriteAllText and File.ReadAllLines/File.WriteAllLinesThe 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.
- Large files:
StreamReader and StreamWriter
StreamReader and StreamWriterWhen 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.
using and IDisposable: releasing resources correctly
using and IDisposable: releasing resources correctlyStreamReader 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 blockSince 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.
- Handling I/O exceptions:
IOException and FileNotFoundException
IOException and FileNotFoundExceptionAny 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.
- 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 astring[]using;as the separator; the field order must match exactly betweenSaveCatalogText(when writing) andLoadCatalogText(when reading).- The
switchexpression (Pattern Matching, Module 4) rebuilds the concrete type (BookorMagazine) from the first field, reusing their constructors exactly as defined in the Inheritance lesson. LoadCatalogTextreusesAddItem, already present inLibrary, instead of callingCatalog.Add(...)directly: the logic for adding an item stays in a single place.- This format is deliberately fragile: if a title contained a
;,Splitwould 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
usingon aStreamReader/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 beforeClose()/Dispose()is called explicitly. Always useusing, without exception, for any type that implementsIDisposable. - Reading a file without checking
File.Existsfirst: tryingFile.ReadAllTexton a nonexistent file throwsFileNotFoundException; check beforehand, or catch the exception if you prefer a "try and catch" approach instead of "check first." - Confusing
File.WriteAllText/WriteAllLineswith "append to the end": both overwrite the entire file if it already existed; useFile.AppendAllText/File.AppendAllLinesif 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/WriteAllLinesare simpler and less error-prone than managing aStreamReader/StreamWriterby hand; save those for genuinely large files or for processing line by line without loading the whole thing.
Exercises
-
Write a code snippet that uses
File.WriteAllLinesto save three book titles into atitles.txtfile, and thenFile.ReadAllLinesto read them back and display them on the console, one per line. -
Write a method
void ShowFileIfExists(string path)that checks withFile.Existswhether the file exists: if it does, read it withFile.ReadAllTextand display it on the console inside atry/catchthat catchesIOException; if it doesn't, display a message saying so, without trying to read it. -
Build
SaveCatalogTextandLoadCatalogTextonLibraryexactly as presented in this lesson. Create aLibrary, add two books and a magazine, save the catalog tocatalog.txt, create a second, emptyLibraryinstance, load the file into it withLoadCatalogText, and check thatCatalog.Countmatches 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.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
