Updated on

.NET gives us thirteen ways to read a text file, and two of them cover almost every case. File.ReadAllText() reads the whole file into one string. File.ReadLines() streams it one line at a time, so memory stays flat however big the file is.

Everything else is a variation. StreamReader gives us the same reads at a lower level when we already hold a stream; ReadBlock() reads fixed-size chunks; BufferedStream adds a second buffer on top of one FileStream already has; and every synchronous read has an asynchronous counterpart.

Which one is fastest depends on the file. We benchmark all thirteen below on a file of roughly 98 KB, where the two whole-file reads, File.ReadAllText() and StreamReader.ReadToEnd(), finish in a dead heat. On a file that does not fit in memory the question changes shape, and so does the answer.

To download the source code for this article, you can visit our GitHub repository.

Preparation of Source File

Before we discuss the different methods to access, let’s create a source file that we can use in all our scenarios.

First, let’s add a string to our project:

private const string ExpectedText
    = """
        Integer facilisis ex libero, ut suscipit leo blandit non. Vivamus nec ipsum orci.
        Proin nec mauris dui. Proin at felis et eros commodo aliquet.
        Pellentesque lacinia porta leo, non accumsan turpis sagittis et.
        Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere.
        """;

We will use this string to create a temporary file in our test class:

private static readonly string TempFilePath = Path.GetTempFileName();

The GetTempFileName() method of the Path class creates a temporary file for us in our temporary folder. Then, it returns the full path to the file. If we would rather not hand our methods an absolute path at all, there is more than one way of reading a text file without specifying the full path.

This file will serve as the sample text file for our test methods.

One assumption runs through the whole sample: the file is not empty. Five of the methods below finish by subtracting Environment.NewLine.Length from the length of a StringBuilder to drop the trailing newline, and on a zero-byte file that builder is empty and the subtraction throws an ArgumentOutOfRangeException. Guarding it in five places would bury the point of each example, so the samples stay at their teaching size and we note the assumption here instead.

Read a Text File With the ReadAllLines Method

With all the configurations done, let’s look at the first method to read a text file:

public string UseFileReadAllLines()
{
    var stringBuilder = new StringBuilder();

    foreach (var line in File.ReadAllLines(_sampleFilePath))
    {
        stringBuilder.AppendLine(line);
    }
    stringBuilder.Length -= Environment.NewLine.Length;

    return stringBuilder.ToString();
}

Here, we invoke the File.ReadAllLines() method to read all the lines in our text file into the memory. This method reads and converts all the lines from our text file into elements within a string array. This string array is returned to us once the entire file has been read.

Then, we define a StringBuilder object that will contain the text from our file.

We utilize the StringBuilder.AppendLine() method to append lines to the final string. Whenever we use this method, the resulting string can differ from the original content of the file.

These differences may arise because, by default, the AppendLine() method uses the newline character set by Environment.NewLine. If, for instance, the file uses \r as its line ending character while the current system adopts \r\n, the returned string might not accurately represent the initial text.

After that, we remove the last newline character by subtracting the Environment.NewLine.Length property value from the stringBuilder‘s length.

Finally, we build and return the string by calling the ToString() method.

How to Read a Text File With the ReadAllText Method

Next up, is the ReadAllText() method:

public string UseFileReadAllText() => File.ReadAllText(_sampleFilePath);

In this method, we invoke the File.ReadAllText() method with our sample file path as the argument. With this concise method, we can directly read all the lines in our file and return them as a single string.

How Do We Read a Text File Line by Line in C#?

File.ReadLines() reads a text file line by line without loading the whole file into memory. It returns an IEnumerable<string>, and each step of the loop pulls the next line off the stream.

That laziness is the whole difference between it and File.ReadAllLines(). ReadAllLines() reads the entire file first and hands back a string[], so a two-gigabyte log lands in memory in one go, and then some: .NET strings are UTF-16, so an ASCII file roughly doubles on the way in. ReadLines() never holds more than the current line.

StreamReader.ReadLine() does the same job one level down. We open the reader ourselves and call ReadLine() until it returns null, which is what we want when the loop has to stop early, skip ahead, or keep a running count.

For a small file the three are interchangeable and the choice is a matter of taste. For a file we cannot fit in memory, only the lazy two are options at all.

To start, let’s define a UseFileReadLines() method:

public string UseFileReadLines()
{
    var stringBuilder = new StringBuilder();

    foreach (var line in File.ReadLines(_sampleFilePath))
    {
        stringBuilder.AppendLine(line);
    }
    stringBuilder.Length -= Environment.NewLine.Length;

    return stringBuilder.ToString();
}

Here, we make use of the File.ReadLines() method to read through our text file line by line.

This method reads the lines from our file and returns an IEnumerable<string> instance that we can use to iterate over our file. Unlike the File.ReadAllLines() method, this method doesn’t load the entire file into memory. Rather, it reads the file line by line in a lazy manner.

Therefore, during iteration over the IEnumerable, each step hands us the next line. The reader still fills an internal buffer, so this is not one disk read per line; what stays constant is how much of the file we are holding, not how many times we touch the disk. After the loop, we remove the last newline character from the stringBuilder.

Lastly, we build and return the string by calling the ToString() method.

Reading line by line is also the shape of most of the work we do with lines, from filtering records to counting the lines in a text file without holding any of it.

Read a Text File With the StreamReader.ReadLine Method

Now, let’s talk about the StreamReader.ReadLine() method:

public string UseStreamReaderReadLine()
{
    using var streamReader = new StreamReader(_sampleFilePath);
    var stringBuilder = new StringBuilder();

    while (streamReader.ReadLine() is { } fileLine)
    {
        stringBuilder.AppendLine(fileLine);
    }
    stringBuilder.Length -= Environment.NewLine.Length;

    return stringBuilder.ToString();
}

Here, we read the lines in our file one by one using a StreamReader object.

We start by initializing a StreamReader instance and providing it with the path to our sample file. We utilize the using statement to ensure proper disposal of the StreamReader at the end of the method execution.

Next, we define a StringBuilder object that we will use in the loop.

Our main action takes place within the while loop. In our loop condition, we make use of pattern-based matching to read lines from our file. As we read each line, we assign it to the variable fileLine. This line is then added to our stringBuilder instance using the stringBuilder.AppendLine() method. This continues until we reach the end of our file and the StreamReader returns a null value.

Again, once we’ve finished reading the file, we make sure to delete all the newline characters at the end of our stringBuilder. Then, we build and return the string by calling the ToString() method.

Read a Text File With the ReadToEnd Method

Moving forward, we have the ReadToEnd() method:

public string UseStreamReaderReadToEnd()
{
    using var streamReader = new StreamReader(_sampleFilePath);

    return streamReader.ReadToEnd();
}

Here, we create a StreamReader instance with our sample file.

Then, we read the entire content of the stream by invoking the StreamReader.ReadToEnd() method.

With this method, we take a different route by reading the entire file content directly into memory all at once.

Use the ReadBlock Method

Additionally, we can use the StreamReader.ReadBlock() method to read a text file:

public string UseStreamReaderReadBlock()
{
    using var streamReader = new StreamReader(_sampleFilePath);
    var buffer = new char[4096];
    int numberRead;
    var stringBuilder = new StringBuilder();

    while ((numberRead = streamReader.ReadBlock(buffer, 0, buffer.Length)) > 0)
    {
        stringBuilder.Append(buffer[..numberRead]);
    }

    return stringBuilder.ToString();
}

Initially, we define a StreamReader for our target file. Then, we create an array of 4096 characters, as this is the default buffer size that FileStream uses.

Also, we define an integer, numberRead to store the number of characters we read during each iteration of the loop. Next, we define a StringBuilder object that we will use in the loop.

In each iteration of the loop, we check whether the number of characters read is still positive. If it is, that means there’s more content in our stream. So, we read the data and add it to our StringBuilder instance.

Inside the loop, we pass in characters from the start of the buffer array up to the character at index numberRead - 1 to the Append() method. With this, we append the string characters that have just been read during the ReadBlock() method operation. We repeat this process until the value of numberRead drops to zero or lower, signifying the end of our file, and then we exit the loop.

When we finish the while loop, we build and return the string by calling the ToString() method.

Moreover, we can utilize the ArrayPool class from the System.Buffers namespace to define our buffer:

public string UseStreamReaderReadBlockWithArrayPool()
{
    using var streamReader = new StreamReader(_sampleFilePath);
    var buffer = ArrayPool<char>.Shared.Rent(4096);
    int numberRead;
    var stringBuilder = new StringBuilder();

    while ((numberRead = streamReader.ReadBlock(buffer, 0, buffer.Length)) > 0)
    {
        stringBuilder.Append(buffer[..numberRead]);
    }

    ArrayPool<char>.Shared.Return(buffer);

    return stringBuilder.ToString();
}

Here, our buffer is a shared instance of the ArrayPool<char> class. We create this instance using the Shared property of the ArrayPool class.

The ArrayPool is a class in .NET that helps us to efficiently create and reuse array instances. By using this class, we reduce our application’s memory demands and enhance its overall performance. We cover how ArrayPool reduces allocations in more depth in its own article.

Then, we proceed to call the Rent() method to retrieve a buffer with a length of 4096. After we are done with the buffer, we return it to the ArrayPool by invoking the Return() method. At the end, we build and return the string by calling the ToString() method.

Read a Text File With the ReadBlock Method and the Span Class

Here, let’s look at another way of using the ReadBlock() method by incorporating the Span class:

public string UseStreamReaderReadBlockWithSpan()
{
    using var streamReader = new StreamReader(_sampleFilePath);
    var buffer = new char[4096].AsSpan();
    int numberRead;
    var stringBuilder = new StringBuilder();

    while ((numberRead = streamReader.ReadBlock(buffer)) > 0)
    {
        stringBuilder.Append(buffer[..numberRead]);
    }

    return stringBuilder.ToString();
}

In this method, we convert our buffer to a Span<char> using the AsSpan() method.

Then, within our while loop definition, we simplify the ReadBlock() method invocation by passing only our buffer, now as a Span. Then we iterate through the file and append the characters in it to our stringBuilder object.

When the loop ends, we build and return the string.

How to Read a Text File With a BufferedStream Object

Next, let’s see how to read a text file by creating a BufferedStream object:

public string UseBufferedStreamObject()
{
    var stringBuilder = new StringBuilder();

    using var fileStream = new FileStream(_sampleFilePath,
                                FileMode.Open,
                                FileAccess.Read,
                                FileShare.Read);
    using var bufferedStream = new BufferedStream(fileStream);
    using var streamReader = new StreamReader(bufferedStream);

    while (streamReader.ReadLine() is { } fileLine)
    {
        stringBuilder.AppendLine(fileLine);
    }
    stringBuilder.Length -= Environment.NewLine.Length;

    return stringBuilder.ToString();
}

In this method, first, we create a new StringBuilder to gather our text. After that, we create an open FileStream instance from our sample file. With that FileStream, we set up a BufferedStream. And using that BufferedStream, we create a StreamReader.

Now comes the real action, the while loop. As long as we keep getting lines using the StreamReader.ReadLine() method, we add them to our stringBuilder using the AppendLine() method. The moment fileLine becomes null, it’s a sign that we’re done reading from our bufferedStream. It also means that we are at the end of our file and we break out of the loop.

When we are done with everything, we remove the last newline character and build and return the string by calling the ToString() method.

Although this method does the reading accurately, the FileStream class employs an internal buffer of 4096 bytes by default. This can lead to double buffering, the buffering of the FileStream and the buffering of the BufferedStream.

Read a Text File With a BufferedStream Object While FileStream Buffering Is Disabled

Lastly, let’s address how we can handle the “double buffering” situation. To do this, let’s disable our FileStream buffering:

public string UseBufferedStreamObjectWithNoFileStreamBuffer()
{
    var stringBuilder = new StringBuilder();

    using var fileStream = new FileStream(_sampleFilePath, new FileStreamOptions
    {
        Mode = FileMode.Open,
        Access = FileAccess.Read,
        Share = FileShare.Read,
        BufferSize = 0
    });
    using var bufferedStream = new BufferedStream(fileStream);
    using var streamReader = new StreamReader(bufferedStream);

    while (streamReader.ReadLine() is { } fileLine)
    {
        stringBuilder.AppendLine(fileLine);
    }
    stringBuilder.Length -= Environment.NewLine.Length;

    return stringBuilder.ToString();
}

We set a BufferSize of 0 to disable buffering, and we do it through the FileStreamOptions overload rather than the older constructor that takes the options as separate arguments. That choice is deliberate. Microsoft Learn’s reference for FileStreamOptions.BufferSize says “0 or 1 means that buffering should be disabled”, while the FileStream constructor overload taking a bufferSize argument documents it as a value greater than zero and lists an ArgumentOutOfRangeException for zero. Both forms run, but only one of them is documented to do what this section is teaching. After this, we go through the same process and then return the string.

How Do We Read a Text File Asynchronously in C#?

Every synchronous read on this page has an asynchronous counterpart, and inside a web application the asynchronous one is the correct default. A blocking read parks a thread pool thread for the whole disk operation, and that thread could have been serving a request.

The catch is that we only get that if the file was opened for it. The File.*Async methods do open it that way; a StreamReader built from a path does not, and the runtime then fakes the asynchrony on the thread pool.

File.ReadAllTextAsync() returns a Task<string> and is the direct replacement for File.ReadAllText(). File.ReadAllLinesAsync() returns a Task<string[]>.

File.ReadLinesAsync() is the lazy one. It arrived in .NET 7 and returns an IAsyncEnumerable<string>, so we consume it with await foreach and still never hold more than one line in memory.

Asynchronous is not the same as faster. On our benchmark file the asynchronous reads take about three times as long as their blocking siblings. What they buy is a free thread, not a shorter wait.

Let’s start with the direct replacement for File.ReadAllText():

public async Task<string> UseFileReadAllTextAsync()
    => await File.ReadAllTextAsync(_sampleFilePath);

There is nothing to arrange here: the method opens the file with asynchronous I/O enabled, reads it to the end, and hands back the string.

Next, the lazy one:

public async Task<string> UseFileReadLinesAsync()
{
    var stringBuilder = new StringBuilder();

    await foreach (var line in File.ReadLinesAsync(_sampleFilePath))
    {
        stringBuilder.AppendLine(line);
    }
    stringBuilder.Length -= Environment.NewLine.Length;

    return stringBuilder.ToString();
}

This mirrors UseFileReadLines() line for line, with await foreach in place of foreach, so the benchmark compares like with like. In real code we would do the work inside the loop and never build the string at all. The pattern is the same one we use when consuming an IAsyncEnumerable with yield.

Finally, the StreamReader version, which is the one that needs care:

public async Task<string> UseStreamReaderReadToEndAsync()
{
    using var fileStream = new FileStream(_sampleFilePath, new FileStreamOptions
    {
        Mode = FileMode.Open,
        Access = FileAccess.Read,
        Share = FileShare.Read,
        Options = FileOptions.Asynchronous | FileOptions.SequentialScan
    });
    using var streamReader = new StreamReader(fileStream);

    return await streamReader.ReadToEndAsync();
}

We build the FileStream ourselves so we can set FileOptions.Asynchronous. Writing new StreamReader(path) and then awaiting ReadToEndAsync() compiles and returns the right text, but the file was opened without that flag, so the read is only asynchronous from our code’s point of view: underneath, a thread pool thread does the blocking wait we were trying to avoid. ReadLineAsync() and ReadBlockAsync() mirror their blocking siblings the same way, and carry the same caveat.

Which Is the Fastest Way to Read a Text File in C#?

On our benchmark file, two methods share the top of the table: File.ReadAllText() and StreamReader.ReadToEnd(). Both make a single pass over the file and hand back one string, they allocate the same amount, and they finish within a couple of microseconds of each other, which is inside the run-to-run noise.

What we asked for matters as much as which method we called. Eight of the ten synchronous methods read lines and then rebuild a single string through a StringBuilder, so they pay for the reassembly as well as the read. ReadToEnd() and File.ReadAllText() skip that step entirely, and that is part of their lead.

Size matters more than either. The file measured here is roughly 98 KB, small enough to sit in the operating system’s cache and small enough that loading it whole costs almost nothing. Make it a gigabyte and the fastest method becomes the one that never loads it whole.

MethodReturnsHolds the whole file in memoryAsync formUse when
File.ReadAllText()stringYesFile.ReadAllTextAsync()We want the whole file as one string
File.ReadAllLines()string[]YesFile.ReadAllLinesAsync()We want indexed access to the lines
File.ReadLines()IEnumerable<string>NoFile.ReadLinesAsync() (.NET 7+)We process line by line, or the file is large
StreamReader.ReadToEnd()stringYesReadToEndAsync()We already hold a stream
StreamReader.ReadLine()string, or null at the endNoReadLineAsync()We need to control the loop ourselves
StreamReader.ReadBlock()int, the character countNoReadBlockAsync()We want fixed-size chunks
RandomAccess.Read()int, the byte countNoRandomAccess.ReadAsync()We read bytes at an offset, possibly from several threads

To compare the methods, we are going to utilize the BenchmarkDotNet library.

Now, let’s run our benchmark and inspect the results on the console:

BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6466/22H2/2022Update)
AMD Ryzen 5 3600 3.60GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK 10.0.302
  [Host]     : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3

| Method                               | Mean     | Error    | StdDev   | Allocated |
|------------------------------------- |---------:|---------:|---------:|----------:|
| FileReadAllText                      | 187.3 us |  1.40 us |  1.09 us | 410.83 KB |
| StreamReaderReadToEnd                | 189.4 us |  3.74 us |  4.60 us | 410.83 KB |
| StreamReaderReadBlockWithSpan        | 193.8 us |  1.41 us |  1.32 us | 418.71 KB |
| StreamReaderReadBlockWithArrayPool   | 208.8 us |  0.86 us |  0.76 us | 609.89 KB |
| StreamReaderReadBlock                | 216.2 us |  3.58 us |  2.99 us | 617.92 KB |
| BufferedStreamWithNoFileStreamBuffer | 237.4 us |  3.09 us |  2.89 us | 626.83 KB |
| StreamReaderReadLine                 | 237.8 us |  3.26 us |  2.89 us | 626.78 KB |
| BufferedStreamObject                 | 240.2 us |  4.77 us |  5.30 us | 626.85 KB |
| FileReadLines                        | 248.7 us |  4.82 us |  4.95 us | 626.84 KB |
| FileReadAllLines                     | 248.9 us |  3.79 us |  4.37 us | 650.89 KB |
| StreamReaderReadToEndAsync           | 539.7 us | 10.65 us | 13.47 us | 415.68 KB |
| FileReadAllTextAsync                 | 551.5 us | 10.91 us | 12.56 us | 420.12 KB |
| FileReadLinesAsync                   | 707.5 us | 13.58 us | 12.70 us | 704.59 KB |

The two fastest ways to read a text file in C# are the two that read it in one pass: File.ReadAllText() and StreamReader.ReadToEnd(). The gap between them here is 2.1 microseconds against error bars of 1.4 and 3.7, and a second run on the same machine reversed the order and left them 0.5 microseconds apart. They are a tie, and either one is the right call when we want the whole file as a string.

Following that, we have the three StreamReader.ReadBlock() methods. The one that uses a Span is the fastest and uses less memory than the other two variants.

The ReadBlock() methods are quite fast because they read the content of our file in blocks. This is generally faster than reading it character by character or line by line.

At the bottom of the synchronous ranking sit the File.ReadAllLines() and File.ReadLines() methods, with StreamReader.ReadLine() and the two BufferedStream variants a little ahead of them. All five walk the file a line at a time and rebuild it through a StringBuilder, and that per-line work is what separates them from the top of the table.

The three asynchronous methods come last by a wide margin, at roughly three times the cost of their blocking siblings. That is the expected shape on a small, cached file, where the overhead of awaiting is the only thing being measured. It is not an argument against using them in a web application, where the point is releasing the thread rather than finishing sooner.

It’s worth noting that methods like the StreamReader.ReadToEnd() method, which loads the complete file into memory, can be effective for small files. However, when dealing with larger files, such methods may lead to substantial memory allocation, potentially impacting the overall performance of our application.

Which Method Should We Use for a Large Text File?

For a file too big to hold comfortably in memory, use File.ReadLines(), or its asynchronous form File.ReadLinesAsync(). Both stream, so memory stays flat however large the file grows, and the process does not fall over on a file it cannot fit.

Two ways to read a text file in C#: reading it whole into a string, and streaming it one line at a time.

The rule of thumb is short. If we need the whole file as one string anyway, read it whole and accept the allocation. If we walk the file record by record and discard as we go, stream it, and never call a method whose return type is an array or a string.

Two extras are worth knowing at this size. FileOptions.SequentialScan tells the operating system we are reading front to back, which it can take as a caching hint. And RandomAccess reads bytes at an explicit offset from a file handle, which is the right tool when several threads read different parts of one large file.

One thing to notice about the samples above: every streaming method on this page reassembles the whole file into a StringBuilder before returning it, which is exactly what streaming is supposed to avoid. They do that so the benchmark compares thirteen methods on identical work. Real streaming code processes each line inside the loop and lets it go, and that is where the flat memory profile actually comes from.

Conclusion

In this article, we have seen thirteen methods that we can use to read a text file in C#, ten of them blocking and three asynchronous. In the end, we compared the time and memory performance of all the methods using benchmark tests. From our benchmark results, File.ReadAllText() and StreamReader.ReadToEnd() finish in a dead heat at the top, and both are the right choice when we want the whole file as one string. When the file is too large for that, File.ReadLines() is the method to reach for.

Writing is the other half of the job, and there are also several ways to overwrite a file in C# once we can read one.

Tested with .NET 10 and BenchmarkDotNet 0.15.8.