Error Handling in I/O

Vaibhav • September 10, 2025

Working with files and streams is powerful - but also risky. Files may be missing, locked, corrupted, or inaccessible due to permissions. Streams may fail mid-operation, and disk space might run out. These are not bugs in your code - they’re real-world conditions your application must be prepared for. In this article, we’ll explore how to handle errors gracefully during file and stream operations in C#, how to anticipate common failure points, and how to build robust I/O logic that doesn’t crash when things go wrong.

Why Error Handling Matters in I/O

File I/O interacts with the operating system and hardware. Unlike pure computation, it depends on external resources that may change or fail. For example:

  • A file might be deleted while your app is running
  • A user might not have permission to write to a folder
  • A network drive might disconnect
  • A file might be locked by another process

Without proper error handling, these issues can crash your application or corrupt data. With good handling, your app can recover, retry, or inform the user clearly.

Using try-catch for File Operations

The most common way to handle I/O errors is with try-catch blocks. Let’s start with a basic example:

try
{
    string content = File.ReadAllText("notes.txt");
    Console.WriteLine(content);
}
catch (FileNotFoundException)
{
    Console.WriteLine("File not found.");
}
catch (UnauthorizedAccessException)
{
    Console.WriteLine("Access denied.");
}
catch (IOException ex)
{
    Console.WriteLine($"I/O error: {ex.Message}");
}

This code attempts to read a file. If the file is missing, locked, or inaccessible, it catches the specific exception and prints a helpful message. The IOException catch-all handles other disk-related issues.

Always catch the most specific exceptions first. This ensures that each error is handled appropriately and avoids masking important details.

Common I/O Exceptions

Here are some exceptions you’ll frequently encounter during file and stream operations:

  • FileNotFoundException - file doesn’t exist
  • DirectoryNotFoundException - folder doesn’t exist
  • UnauthorizedAccessException - permission denied
  • IOException - general I/O error (e.g., file in use)
  • PathTooLongException - path exceeds system limit
  • NotSupportedException - invalid path format

You can catch these individually or group them under IOException if you want a simpler handler.

Validating Before Access

One way to reduce errors is to check conditions before performing I/O. For example:

string path = "data.txt";

if (File.Exists(path))
{
    string content = File.ReadAllText(path);
    Console.WriteLine(content);
}
else
{
    Console.WriteLine("File does not exist.");
}

This avoids a FileNotFoundException by checking first. However, it’s not foolproof - the file might be deleted between the check and the read. So you still need a try-catch as backup.

File system state can change at any time. Always treat pre-checks as hints, not guarantees.

Handling Stream Errors

Streams can fail during reading or writing. For example, a network stream might disconnect, or a file stream might hit a locked file. Here’s how to handle stream errors:

try
{
    using FileStream fs = new FileStream("output.txt", FileMode.Create);
    byte[] data = Encoding.UTF8.GetBytes("Hello, world!");
    fs.Write(data, 0, data.Length);
}
catch (IOException ex)
{
    Console.WriteLine($"Stream error: {ex.Message}");
}

This writes bytes to a file. If the disk is full or the file is locked, the IOException will be caught and reported.

Handling Encoding Errors

When reading or writing text, encoding mismatches can cause errors. For example, reading a UTF-16 file as UTF-8 may produce garbage or throw exceptions. You can specify encoding explicitly:

try
{
    using StreamReader reader = new StreamReader("legacy.txt", Encoding.GetEncoding("ISO-8859-1"));
    string content = reader.ReadToEnd();
    Console.WriteLine(content);
}
catch (DecoderFallbackException)
{
    Console.WriteLine("Encoding error: unable to decode file.");
}

This reads a file using a legacy encoding. If the bytes can’t be decoded, a DecoderFallbackException is thrown.

Handling File Locks and Sharing Violations

If another process is using a file, your app may get a sharing violation. You can handle this by retrying or informing the user:

try
{
    using FileStream fs = new FileStream("shared.txt", FileMode.Open, FileAccess.Read, FileShare.None);
    // Read file
}
catch (IOException ex)
{
    Console.WriteLine("File is in use by another process.");
}

This attempts to open a file with exclusive access. If another app has it open, the operation fails.

Retrying Failed Operations

Sometimes, a transient error (like a locked file) can be resolved by retrying after a short delay. Here’s a simple retry pattern:

int attempts = 0;
while (attempts < 3)
{
    try
    {
        string content = File.ReadAllText("data.txt");
        Console.WriteLine(content);
        break;
    }
    catch (IOException)
    {
        attempts++;
        Thread.Sleep(500); // Wait before retrying
    }
}

This tries up to three times before giving up. You can adjust the delay and number of attempts based on your scenario.

Logging Errors for Diagnostics

Instead of just printing errors, you can log them to a file for later analysis:

try
{
    // Some I/O operation
}
catch (Exception ex)
{
    File.AppendAllText("error.log", $"{DateTime.Now}: {ex.Message}\n");
}

This appends the error message to a log file with a timestamp. You can also log stack traces or error codes.

Handling Permissions and Access Control

If your app tries to access a protected folder (like C:\Windows), it may fail with UnauthorizedAccessException. You can check permissions or guide the user:

try
{
    File.WriteAllText("C:\\System\\config.txt", "data");
}
catch (UnauthorizedAccessException)
{
    Console.WriteLine("You do not have permission to write to this location.");
}

This helps avoid silent failures and gives users actionable feedback.

Handling Path Errors

Paths can be malformed, too long, or contain invalid characters. You can validate before using them:

string path = "C:\\Invalid|Path.txt";

try
{
    File.WriteAllText(path, "data");
}
catch (ArgumentException)
{
    Console.WriteLine("Invalid path format.");
}
catch (PathTooLongException)
{
    Console.WriteLine("Path is too long.");
}

This catches common path-related errors and informs the user.

Using Defensive Defaults

When reading configuration files or optional data, it’s good to fall back to defaults if the file is missing:

string theme = "Light";

try
{
    theme = File.ReadAllText("theme.txt");
}
catch (FileNotFoundException)
{
    Console.WriteLine("Theme file missing. Using default.");
}

This ensures your app continues to work even if the config file is missing.

Summary

Error handling is a critical part of working with file and stream I/O in C#. You’ve learned how to use try-catch blocks to catch common exceptions, validate paths and permissions, retry failed operations, and log errors for diagnostics. These techniques help you build robust applications that don’t crash when the file system misbehaves - and instead recover gracefully or guide the user. In the next chapter, we’ll explore generics and type safety - unlocking the power of reusable, strongly typed components that adapt to any data type.