Skip to main content
C# intermediate Lesson 18 of 25

File I/O in C#

File class, StreamReader/Writer, JSON with System.Text.Json, and async file I/O in C#.

The File Class — Quick Operations

The System.IO.File static class provides the simplest way to read and write files when the file is small enough to fit in memory. It handles opening, reading or writing, and closing in a single method call, which eliminates boilerplate and reduces the risk of forgetting to close the file handle. For larger files or when you need fine-grained control over encoding or buffering, use the stream-based APIs below.

using System.IO;

string path = "notes.txt";

// Write (creates or overwrites the file)
File.WriteAllText(path, "Hello, File I/O!");
File.WriteAllLines(path, new[] { "Line 1", "Line 2", "Line 3" });

// Read the entire file at once
string content = File.ReadAllText(path);
string[] lines = File.ReadAllLines(path);
byte[] bytes   = File.ReadAllBytes("image.png");

// Append to an existing file without overwriting
File.AppendAllText(path, "\nAppended line");
File.AppendAllLines(path, new[] { "More", "Lines" });

// Check existence and inspect metadata
bool exists = File.Exists(path);
FileInfo info = new FileInfo(path);
Console.WriteLine($"Size: {info.Length} bytes, Modified: {info.LastWriteTime}");

// Copy, move, and delete
File.Copy("source.txt", "dest.txt", overwrite: true);
File.Move("old.txt", "new.txt", overwrite: true);
File.Delete("temp.txt");

Directory Operations

Working with directories in .NET is straightforward, but one important habit is to always use Path.Combine instead of string concatenation to build paths. Hardcoded separators (\\ or /) break on other operating systems; Path.Combine handles the correct separator for the current platform and also resolves relative segments.

using System.IO;

// Create a directory and all intermediate directories in one call
Directory.CreateDirectory("output/reports");
bool exists = Directory.Exists("output");

// List directory contents
string[] files = Directory.GetFiles(".", "*.txt");
string[] dirs  = Directory.GetDirectories(".", "*", SearchOption.AllDirectories);

// Enumerate lazily — better for large directories as it doesn't load all paths at once
foreach (string file in Directory.EnumerateFiles("logs", "*.log", SearchOption.AllDirectories))
    Console.WriteLine(file);

// Path manipulation — always use Path, never string concatenation
string full = Path.Combine("C:\\Users", "Alice", "docs", "file.txt");
string ext  = Path.GetExtension("report.pdf");              // .pdf
string name = Path.GetFileNameWithoutExtension("report.pdf"); // report
string dir  = Path.GetDirectoryName(full)!;                  // C:\Users\Alice\docs
string temp = Path.GetTempFileName();                        // unique temp file path

StreamReader and StreamWriter

For large files, loading the entire content into memory at once is wasteful and can cause OutOfMemoryException. StreamReader reads line by line, keeping only one line in memory at a time, which lets you process files of any size. StreamWriter works similarly for output — it buffers writes and flushes them to disk efficiently. Both implement IDisposable, so always use using.

// Read a large log file line by line — only one line in memory at a time
using var reader = new StreamReader("large.log");
int lineNumber = 0;
while (reader.ReadLine() is string line)
{
    lineNumber++;
    if (line.Contains("ERROR"))
        Console.WriteLine($"Line {lineNumber}: {line}");
}

// Write with StreamWriter — controls encoding and append mode
using var writer = new StreamWriter("output.txt", append: false, encoding: System.Text.Encoding.UTF8);
writer.WriteLine("Header");
for (int i = 0; i < 1000; i++)
    writer.WriteLine($"Record {i}");
// Disposed automatically on exit — flushes the internal buffer to disk

// Append to an existing file (append: true)
using var appender = new StreamWriter("log.txt", append: true);
appender.WriteLine($"[{DateTime.Now:O}] Application started");

Async File I/O

Synchronous file reads and writes block the calling thread for the duration of the disk operation. In async applications — especially web servers — this wastes thread pool capacity. All the common file operations have async counterparts that release the thread during I/O. Prefer them in any async method.

// Async equivalents of the File class methods
string content = await File.ReadAllTextAsync("config.json");
string[] lines = await File.ReadAllLinesAsync("data.csv");
byte[] bytes   = await File.ReadAllBytesAsync("file.bin");

await File.WriteAllTextAsync("output.txt", result);
await File.WriteAllLinesAsync("report.txt", reportLines);

// Async StreamReader — line by line on large files without blocking
await using var reader = new StreamReader("large.csv");
while (await reader.ReadLineAsync() is string line)
{
    var record = ParseCsvLine(line);
    await ProcessRecordAsync(record);
}

// Async StreamWriter
await using var writer = new StreamWriter("results.txt");
await writer.WriteLineAsync("Results:");
await writer.WriteLineAsync($"Total: {total}");

// FileStream for async binary data — set useAsync: true for true async I/O
await using var fs = new FileStream("data.bin",
    FileMode.Create, FileAccess.Write,
    FileShare.None, bufferSize: 4096,
    useAsync: true);

byte[] buffer = GenerateData();
await fs.WriteAsync(buffer.AsMemory());

JSON with System.Text.Json

System.Text.Json is the built-in, high-performance JSON library in .NET 5+. It handles serialization (object → JSON string) and deserialization (JSON string → object) with minimal allocations. For reading and writing JSON files, stream-based serialization is preferred over string-based because it avoids loading the entire JSON string into memory.

using System.Text.Json;
using System.Text.Json.Serialization;

// Model
public record UserSettings(
    string Theme,
    bool Notifications,
    List<string> FavoriteCategories);

// Serialize to a JSON string
var settings = new UserSettings("dark", true, new() { "C#", "DevOps" });
string json = JsonSerializer.Serialize(settings);
// {"Theme":"dark","Notifications":true,"FavoriteCategories":["C#","DevOps"]}

// Pretty-print for human-readable output (config files, debug logging)
string pretty = JsonSerializer.Serialize(settings, new JsonSerializerOptions
{
    WriteIndented = true
});

// Deserialize back to an object
UserSettings? loaded = JsonSerializer.Deserialize<UserSettings>(json);

// Common options — camelCase properties, null exclusion, enum-as-string
var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,         // PascalCase → camelCase
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    WriteIndented = true,
    Converters = { new JsonStringEnumConverter() }             // enum as "Active" not 1
};

// Stream-based — avoids loading entire JSON string into memory
await using var readStream  = File.OpenRead("settings.json");
var config = await JsonSerializer.DeserializeAsync<UserSettings>(readStream, options);

await using var writeStream = File.Create("settings.json");
await JsonSerializer.SerializeAsync(writeStream, settings, options);

Custom JSON Converters

When the default serialization behavior does not fit — for example, DateOnly is not natively supported in older .NET versions — you can write a custom JsonConverter to control exactly how a type is read and written. Register the converter once in JsonSerializerOptions and it applies everywhere.

public class DateOnlyConverter : JsonConverter<DateOnly>
{
    // Read: parse the JSON string value into a DateOnly
    public override DateOnly Read(ref Utf8JsonReader reader, Type type, JsonSerializerOptions options)
        => DateOnly.Parse(reader.GetString()!);

    // Write: format the DateOnly as an ISO 8601 date string
    public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
        => writer.WriteStringValue(value.ToString("yyyy-MM-dd"));
}

// Register and use
var options = new JsonSerializerOptions();
options.Converters.Add(new DateOnlyConverter());

Working with Paths Cross-Platform

Hardcoded path separators and absolute paths are fragile — they break when moving between Windows and Linux, or when a user installs your app in a non-standard location. Use Path.Combine and Environment.GetFolderPath to build paths that work correctly on every platform and respect the user’s environment.

// Build the path to the user's app data folder — works on Windows, macOS, and Linux
string configPath = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
    "MyApp",
    "config.json");

// Ensure the directory exists before writing
Directory.CreateDirectory(Path.GetDirectoryName(configPath)!);

// Normalize a path with relative segments
string normalized = Path.GetFullPath("./data/../output/file.txt");

// Get well-known folders without hardcoding platform-specific paths
string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
string temp    = Path.GetTempPath();
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);

Watching for File Changes

FileSystemWatcher lets your application react to file system events in real time — a configuration file being updated, a log file being created, or a file being deleted. This is useful for hot-reload scenarios, file-processing pipelines, and developer tooling. The events fire on a background thread, so be careful about thread safety when accessing shared state from the handlers.

using var watcher = new FileSystemWatcher("C:\\Logs")
{
    Filter = "*.log",                                                   // only watch .log files
    NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName,    // which changes to detect
    EnableRaisingEvents = true,
    IncludeSubdirectories = false
};

watcher.Changed += (sender, e) =>
    Console.WriteLine($"File changed: {e.FullPath}");

watcher.Created += (sender, e) =>
    Console.WriteLine($"File created: {e.FullPath}");

watcher.Deleted += (sender, e) =>
    Console.WriteLine($"File deleted: {e.FullPath}");

Console.WriteLine("Watching... press Enter to stop");
Console.ReadLine();

CSV Processing Example

Combining async file I/O with IAsyncEnumerable creates an efficient streaming pipeline for large CSV files. Each record is yielded and consumed as it is read from disk — no buffering, no large in-memory collections, and full cancellation support. This pattern scales to files of any size.

public async IAsyncEnumerable<SalesRecord> ReadSalesCsvAsync(
    string path,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await using var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);

    // Skip the header row
    await reader.ReadLineAsync(ct);

    while (await reader.ReadLineAsync(ct) is string line)
    {
        var parts = line.Split(',');
        if (parts.Length < 4) continue;  // skip malformed rows
        yield return new SalesRecord(
            Date:     DateOnly.Parse(parts[0]),
            Product:  parts[1],
            Quantity: int.Parse(parts[2]),
            Revenue:  decimal.Parse(parts[3]));
    }
}

// Usage — process one record at a time as they stream from disk
await foreach (var record in ReadSalesCsvAsync("sales.csv"))
    Console.WriteLine($"{record.Date}: {record.Product} x{record.Quantity} = ${record.Revenue}");

Frequently Asked Questions

When should I use File.ReadAllText vs StreamReader?
Use File.ReadAllText for small files that fit comfortably in memory. Use StreamReader for large files you want to read line by line without loading everything at once. Both have async variants — prefer the async versions in async methods.
Is System.Text.Json faster than Newtonsoft.Json?
Yes, System.Text.Json is significantly faster and allocates less memory. It is the default in .NET 5+. Newtonsoft.Json is still useful for its richer feature set (custom converters, dynamic objects, LINQ to JSON), but for most cases System.Text.Json is the right choice.
What is the difference between File.WriteAllText and StreamWriter?
File.WriteAllText writes a string to a file in one call, overwriting any existing content. StreamWriter gives you incremental control — you can append line by line, control buffering, and write to streams other than files (network, memory, etc.).