C# · lesson 19 of 26
File I/O in C#
C# file I/O by example: the File and Directory classes, Path, StreamReader and StreamWriter, async reads, System.Text.Json, and FileSystemWatcher.
By SkillByExample · Updated
The File Class
The static File class opens, reads or writes, and closes a file in one call. It is the right tool when the file comfortably fits in memory.
string path = "notes.txt";
File.WriteAllText(path, "Hello, File I/O!");
File.WriteAllLines(path, new[] { "Line 1", "Line 2" });
File.AppendAllText(path, "Line 3" + Environment.NewLine);
string content = File.ReadAllText(path);
string[] lines = File.ReadAllLines(path);
Console.Write(content);
Console.WriteLine($"{lines.Length} lines, {new FileInfo(path).Length} bytes");
File.Copy(path, "copy.txt", overwrite: true);
File.Move("copy.txt", "moved.txt", overwrite: true);
Console.WriteLine($"{File.Exists("copy.txt")} {File.Exists("moved.txt")}");
File.Delete("moved.txt");
File.Delete("moved.txt");
try { File.ReadAllText("nope.txt"); }
catch (FileNotFoundException ex) { Console.WriteLine(ex.Message.Replace(Environment.CurrentDirectory, ".")); }
$ dotnet run
Line 1
Line 2
Line 3
3 lines, 21 bytes
False True
Could not find file './nope.txt'.
WriteAllLines replaced the first text entirely — every WriteAll... overwrites. 21 bytes is three 6-character lines plus three \n newlines on Linux (it would be 24 on Windows, where Environment.NewLine is \r\n). Deleting a file that does not exist is silently fine, but reading one throws. (The program swaps the absolute working directory for . in the message.)
Directories and Paths
Directory creates and lists folders; Path builds and takes apart paths without you ever typing a separator.
Directory.CreateDirectory(Path.Combine("output", "reports", "2024"));
File.WriteAllText(Path.Combine("output", "a.txt"), "a");
File.WriteAllText(Path.Combine("output", "reports", "b.log"), "b");
File.WriteAllText(Path.Combine("output", "reports", "2024", "c.log"), "c");
foreach (var f in Directory.EnumerateFiles("output", "*.log", SearchOption.AllDirectories).Order())
Console.WriteLine(f);
Console.WriteLine(Directory.GetFiles("output").Length);
string full = Path.Combine("data", "in", "report.final.pdf");
Console.WriteLine(full);
Console.WriteLine($"{Path.GetFileName(full)} | {Path.GetFileNameWithoutExtension(full)} | {Path.GetExtension(full)}");
Console.WriteLine(Path.GetDirectoryName(full));
Console.WriteLine(Path.ChangeExtension(full, ".txt"));
Console.WriteLine(Path.Combine("data", "/etc/passwd"));
Console.WriteLine(Path.GetRelativePath("output", Path.GetFullPath("output/reports/../a.txt")));
$ dotnet run
output/reports/2024/c.log
output/reports/b.log
1
data/in/report.final.pdf
report.final.pdf | report.final | .pdf
data/in
data/in/report.final.txt
/etc/passwd
a.txt
CreateDirectory makes every missing parent and does nothing if the folder exists. GetFiles("output") found only one file because it is not recursive unless you pass SearchOption.AllDirectories. GetExtension returns only the last extension, .pdf. Watch the Path.Combine("data", "/etc/passwd") line: a rooted second argument discards everything before it — if that argument comes from user input, validate it first.
StreamReader and StreamWriter
For large files, read and write a line at a time so only one line is in memory. Writers buffer, so nothing is guaranteed to be on disk until the writer is flushed or disposed.
var writer = new StreamWriter("big.log");
for (int i = 1; i <= 100_000; i++)
writer.WriteLine(i % 25_000 == 0 ? $"{i} ERROR disk slow" : $"{i} INFO ok");
Console.WriteLine($"before dispose: {new FileInfo("big.log").Length} bytes on disk");
writer.Dispose();
Console.WriteLine($"after dispose: {new FileInfo("big.log").Length} bytes on disk");
using var reader = new StreamReader("big.log");
int lineNumber = 0;
while (reader.ReadLine() is string line)
{
lineNumber++;
if (line.Contains("ERROR"))
Console.WriteLine($"line {lineNumber}: {line}");
}
$ dotnet run
before dispose: 1384448 bytes on disk
after dispose: 1388927 bytes on disk
line 25000: 25000 ERROR disk slow
line 50000: 50000 ERROR disk slow
line 75000: 75000 ERROR disk slow
line 100000: 100000 ERROR disk slow
Before Dispose, most of the file was on disk but the last buffered chunk — about 4.5 KB here — was not — kill the process at that point and the tail of the log is gone. That is why writers belong in a using. The reader loop never holds more than one line, so it works the same on a 10 GB file.
Async File I/O
The ...Async methods free the thread while the disk works, which matters in servers handling many requests. IAsyncEnumerable lets you stream records as they are read.
using System.Runtime.CompilerServices;
await File.WriteAllLinesAsync("sales.csv", new[]
{
"date,product,qty,revenue",
"2024-01-15,Widget,3,29.97",
"2024-01-15,Gadget,1,49.99",
"bad row",
"2024-01-16,Widget,5,49.95",
});
decimal total = 0;
await foreach (var r in ReadSalesAsync("sales.csv"))
{
Console.WriteLine($"{r.Date:yyyy-MM-dd} {r.Product,-7} x{r.Qty}");
total += r.Revenue;
}
Console.WriteLine($"total {total}");
static async IAsyncEnumerable<Sale> ReadSalesAsync(string path, [EnumeratorCancellation] CancellationToken ct = default)
{
using var reader = new StreamReader(path);
await reader.ReadLineAsync(ct);
while (await reader.ReadLineAsync(ct) is string line)
{
var p = line.Split(',');
if (p.Length < 4) continue;
yield return new Sale(DateOnly.Parse(p[0]), p[1], int.Parse(p[2]), decimal.Parse(p[3]));
}
}
record Sale(DateOnly Date, string Product, int Qty, decimal Revenue);
$ dotnet run
2024-01-15 Widget x3
2024-01-15 Gadget x1
2024-01-16 Widget x5
total 129.91
The header is skipped with one ReadLineAsync and the malformed row by the length check. await foreach pulls one Sale at a time, so processing starts before the file is fully read. DateOnly.Parse and decimal.Parse use the current culture; for files from elsewhere, pass CultureInfo.InvariantCulture.
JSON with System.Text.Json
JsonSerializer turns objects into JSON and back. Options control naming, indentation, nulls and enums.
using System.Text.Json;
using System.Text.Json.Serialization;
var settings = new UserSettings("dark", Level.Pro, null, new() { "C#", "DevOps" });
Console.WriteLine(JsonSerializer.Serialize(settings));
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
await using (var fs = File.Create("settings.json"))
await JsonSerializer.SerializeAsync(fs, settings, options);
Console.WriteLine(File.ReadAllText("settings.json"));
await using var rs = File.OpenRead("settings.json");
var loaded = await JsonSerializer.DeserializeAsync<UserSettings>(rs, options);
Console.WriteLine(loaded!.Favorites.Count + " " + (loaded == settings));
var strict = JsonSerializer.Deserialize<UserSettings>("""{"theme":"light"}""");
Console.WriteLine($"Theme={strict!.Theme ?? "(null)"} Favorites={strict.Favorites?.Count.ToString() ?? "(null)"}");
public enum Level { Free, Pro }
public record UserSettings(string Theme, Level Level, string? Nickname, List<string> Favorites);
$ dotnet run
{"Theme":"dark","Level":1,"Nickname":null,"Favorites":["C#","DevOps"]}
{
"theme": "dark",
"level": "Pro",
"favorites": [
"C#",
"DevOps"
]
}
2 False
Theme=(null) Favorites=(null)
By default the enum is written as a number and null is written out; the options write the enum as its name, "Pro" (the naming policy renames properties, not enum values), camelCase the property names, drop the null, and indent. The round-tripped object has the same data but loaded == settings is False — record equality compares the List<string> by reference. The last line is the classic surprise: with default options, matching is case-sensitive, so "theme" did not bind to Theme, and a missing property silently becomes null rather than an error. ASP.NET Core uses case-insensitive web defaults (JsonSerializerDefaults.Web) for exactly this reason.
Watching for File Changes
FileSystemWatcher raises events when files in a folder are created, changed, renamed or deleted.
using System.Collections.Concurrent;
Directory.CreateDirectory("watched");
var events = new ConcurrentQueue<string>();
using var watcher = new FileSystemWatcher("watched", "*.log")
{
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite,
EnableRaisingEvents = true,
};
watcher.Created += (_, e) => events.Enqueue($"Created {e.Name}");
watcher.Renamed += (_, e) => events.Enqueue($"Renamed {e.OldName} -> {e.Name}");
watcher.Deleted += (_, e) => events.Enqueue($"Deleted {e.Name}");
File.WriteAllText("watched/app.log", "start");
File.WriteAllText("watched/notes.txt", "ignored by the *.log filter");
File.Move("watched/app.log", "watched/app.1.log");
File.Delete("watched/app.1.log");
var deadline = DateTime.UtcNow.AddSeconds(5);
while (events.Count < 3 && DateTime.UtcNow < deadline)
await Task.Delay(20);
foreach (var e in events) Console.WriteLine(e);
$ dotnet run
Created app.log
Renamed app.log -> app.1.log
Deleted app.1.log
Events arrive on a thread-pool thread, which is why they go into a ConcurrentQueue and the main code polls for the expected count rather than sleeping a fixed time. notes.txt produced nothing because of the *.log filter. Real watchers need two more habits: expect duplicate Changed events for one save (editors often write a file in several steps), and do the actual work outside the event handler.
Practice
- Tail a file. Write the numbers 1-50 to
n.txt, one per line, then print the last three lines withoutReadAllLines.
Solution
File.WriteAllLines("n.txt", Enumerable.Range(1, 50).Select(i => i.ToString()));
var last = new Queue<string>();
foreach (var line in File.ReadLines("n.txt"))
{
last.Enqueue(line);
if (last.Count > 3) last.Dequeue();
}
Console.WriteLine(string.Join(",", last));
$ dotnet run
48,49,50
File.ReadLines is lazy, unlike ReadAllLines; the queue keeps only three lines at a time.
- Case-insensitive JSON. Fix the last JSON example so
{"theme":"light"}binds toTheme.
Solution
using System.Text.Json;
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var s = JsonSerializer.Deserialize<Settings>("""{"theme":"light"}""", web);
Console.WriteLine(s!.Theme);
record Settings(string Theme);
$ dotnet run
light
JsonSerializerDefaults.Web turns on case-insensitive matching and camelCase naming.
- Folder size report. Create a folder with three files of different sizes and print each file’s size plus the total, largest first.
Solution
Directory.CreateDirectory("data");
File.WriteAllText("data/a.txt", new string('a', 100));
File.WriteAllText("data/b.txt", new string('b', 2500));
File.WriteAllText("data/c.txt", new string('c', 40));
var files = new DirectoryInfo("data").GetFiles().OrderByDescending(f => f.Length).ToList();
foreach (var f in files)
Console.WriteLine($"{f.Name} {f.Length,6:N0} B");
Console.WriteLine($"total {files.Sum(f => f.Length):N0} B");
$ dotnet run
b.txt 2,500 B
a.txt 100 B
c.txt 40 B
total 2,640 B
DirectoryInfo.GetFiles returns FileInfo objects, so sizes come without opening each file.