Reading Files

Learn how to read byte sequences from files using the FileStream class and ensure proper disposal of operating system file handles.

A common example of an unmanaged resource is a file residing on a disk. To open it, read it, write to it, or delete it, any programming language must ultimately settle with operating system calls. Because the operating system does not run on top of the CLR, OS calls are considered unmanaged code.

Therefore, we must appropriately dispose of objects that use such external calls. Before we start creating and disposing of file objects, we must first understand how .NET works with files.

The Stream class

A file is fundamentally nothing but a sequence of bytes. Depending on the file format, this sequence is interpreted differently, allowing the user to see meaningful output. Interpreting an image file's bytes as text results in invalid or unreadable characters.

In .NET, a sequence of bytes is represented by the Stream class inside the System.IO namespace. It is an abstract base class for all streams in .NET.

There are two main operations provided by streams:

  • Reading: This represents the transfer of bytes from the external environment (such as a file, a network connection, or a keyboard) into the program. For instance, we can read from the stream and save everything as an array of bytes (byte[]).

  • Writing: This represents the transfer of bytes from the application out to the external environment (from a byte[] to a file, network connection, or output device).

Depending on the source of a byte sequence, there are various Stream types in .NET. For network access, we have NetworkStream, and for files, we have FileStream. In this lesson, we are interested in the latter.

How to check if a file exists

Before opening a file, we must verify that it actually exists on the disk. Attempting to open a non-existent file for reading throws a FileNotFoundException and crashes the application.

We use the File.Exists method from the System.IO namespace to perform this validation safely. This method takes a string representing the file path and returns a boolean value indicating the file’s presence.

Notably, File.Exists robustly handles edge cases without throwing exceptions. It safely returns false under any of the following conditions:

  • The file does not exist at the specified path.

  • The provided path is null, empty, or contains invalid characters.

  • The application lacks the necessary operating system permissions to read the path.

  • The provided path points to a directory rather than a file.

C# 14.0
using System.IO;
string filePath = "Program.cs";
if (File.Exists(filePath))
{
Console.WriteLine("The file exists. We can proceed with reading.");
}
else
{
Console.WriteLine("The file does not exist. Halting operation.");
}
  • Line 7: We pass the file path into the File.Exists method. If the operating system confirms the file is present at that location, it returns true.

Reading with FileStream

The FileStream class provides a stream to interact with a file. It supports read and write operations in both synchronous and asynchronous manners. With FileStream, we can work with both text and binary files.

To create an instance of FileStream, we use one of its constructors:

var fileStream = new FileStream(path, mode);

Here, we pass the file path and an enumeration value dictating how the operating system should open the file.

The path (string object) is the location of the file we want to open, and mode is an enumeration of type FileMode that can have one of the following values:

  • Append: If the file exists, bytes are appended to the end of the file. If the file does not exist, it is created. The file is opened exclusively for writing.

  • Create: A new file is created. If a file with the same name already exists, it is overwritten.

  • CreateNew: This attempts to create a new file and open it. An exception is thrown if a file with the same name already exists.

  • Open: This opens the file and throws an exception if no such file exists.

  • OpenOrCreate: This opens the file or creates a new one if it does not exist.

  • Truncate: This attempts to open an existing file and overwrite its contents entirely.

Before reading a file with a FileStream object, we can list the available files in our directory. We can make use of the Directory static class, which lets us interact with the file system’s directory structure.

C# 14.0
using System.IO;
// Gets the current directory where the app executable is located
string currentDirectory = Directory.GetCurrentDirectory();
// Gets the list of files
string[] files = Directory.GetFiles(currentDirectory);
Console.WriteLine("Files in current directory:");
foreach (var file in files)
{
Console.WriteLine(file);
}
  • Line 1: We import the System.IO namespace, which contains the classes required for input and output operations.

  • Line 4: We retrieve the absolute path of the directory from which the application is currently executing.

  • Line 7: We request an array of strings representing the full paths of all files in that directory.

  • Lines 10–13: We iterate through the array and print each file path to the console.

Assuming we have a file named Program.cs in our directory, let us read its contents using a FileStream instance. To read, we use the Read() method, which has the following signature:

Read(byte[] buffer, int offset, int bytesToRead);

Here, we define the buffer (the destination to store the read bytes), the offset (the starting position in the array), and the bytesToRead (how many bytes to pull from the file).

Now, let us use this method to extract the bytes from the Program.cs file and convert them back into readable text.

C# 14.0
using System.IO;
using System.Text;
// We create an instance of the FileStream class by providing the file name and the mode
var fileStream = new FileStream("Program.cs", FileMode.Open);
// We should have a place to store the sequence of bytes in our program
// As the size of our byte array, we provide fileStream.Length
byte[] byteSequence = new byte[fileStream.Length];
// We read the file bytes into the byteSequence object
// We start reading from the beginning and read until the end
fileStream.Read(byteSequence, 0, byteSequence.Length);
// We must decode this sequence of bytes to see it as text
// We use the Encoding class for this conversion
string text = Encoding.UTF8.GetString(byteSequence);
Console.WriteLine(text);
  • Line 5: We open a file stream targeting Program.cs with the instruction to strictly open an existing file.

  • Line 9: We initialize a byte array. We set its size to match the exact length of the file using fileStream.Length.

  • Line 13: We instruct the stream to read bytes from the file and place them into byteSequence, starting at index 0, until the array is full.

  • Line 17: We use the Encoding.UTF8 utility to translate the raw bytes back into a human-readable C# string.

  • Line 19: We print the resulting text to the console.

If we run this, we will print out the exact source code that we just used to read the source code!

Dispose

Although the previous code works, it fails to release the file handle. A file is an unmanaged resource, and any connection to an unmanaged resource must be properly closed after the work is done. In the previous code, we did not release the file handle.

As we learned previously, unmanaged resources can be handled reliably using the using construct. However, to use the using keyword, the object's class must implement the IDisposable interface. Let us use reflection to inspect the FileStream class and verify if it implements this interface.

C# 14.0
using System.IO;
Type fileStreamTypeInfo = typeof(FileStream);
Type[] implementedInterfaces = fileStreamTypeInfo.GetInterfaces();
foreach (var iface in implementedInterfaces)
{
Console.WriteLine(iface.Name);
}
  • Line 3: We retrieve the runtime metadata for the FileStream type.

  • Line 4: We extract an array of all interfaces that FileStream implements.

  • Lines 6–9: We iterate through the interfaces and print their names to the console.

If we run this code, we will see IDisposable in the output. This confirms that FileStream implements the IDisposable interface, meaning we can (and should) declare our stream with a using declaration.

Let us rewrite our file-reading logic using the modern using declaration to ensure the file handle is safely released.

C# 14.0
using System.IO;
using System.Text;
// Dispose() is guaranteed to be called at the end of the method/file
using var fileStream = new FileStream("Program.cs", FileMode.Open);
byte[] byteSequence = new byte[fileStream.Length];
fileStream.Read(byteSequence, 0, byteSequence.Length);
string text = Encoding.UTF8.GetString(byteSequence);
Console.WriteLine(text);
  • Line 5: We prepend the var declaration with the using keyword. This ensures that the operating system file handle is automatically released when the application finishes executing the current scope.

  • Lines 7–11: We execute the same reading and decoding logic, but now it is fully protected against memory leaks and file locks.

Modern best practices

While understanding how FileStream works is crucial, modern developers rarely manipulate raw byte arrays manually to read standard text files. The System.IO.File class provides higher-level utility methods. Specifically, File.ReadAllTextAsync() automatically handles opening the stream, reading the bytes, decoding the text into a string, and safely disposing of the unmanaged resources.

Furthermore, we use the Path.Combine() method to build file paths safely. Different operating systems use different directory separator characters. Windows uses a backslash (\), whereas Linux and macOS use a forward slash (/). Path.Combine() automatically applies the correct separator based on the current operating environment.

C# 14.0
using System.IO;
// Build the path safely across all operating systems
string currentDirectory = Directory.GetCurrentDirectory();
string filePath = Path.Combine(currentDirectory, "Program.cs");
// Read the entire file asynchronously and safely
string text = await File.ReadAllTextAsync(filePath);
Console.WriteLine(text);
  • Line 5: We construct the full file path safely without hardcoding any directory separators.

  • Line 8: We use the await keyword to non-blockingly retrieve the entire string content of the file. The method handles the FileStream and Dispose logic implicitly.