A Stream in .NET deals in raw bytes. Files of text, on the other hand, are sequences of characters that need an encoding to translate between the two. StreamReader and StreamWriter are the text-oriented wrappers that sit on top of any byte stream and handle that translation, so code can read and write strings, lines, and characters instead of byte arrays. This lesson covers what those two classes do, the encoding choices that bridge bytes and characters, the constructors and convenience helpers, the streaming patterns that keep memory usage low on huge files, and the disposal rules that decide what happens to the underlying stream.
The base Stream class provides Read(byte[] buffer, int offset, int count) and Write(byte[] buffer, int offset, int count). Both speak in bytes. If a file holds the text Hello, the stream sees five bytes: 0x48 0x65 0x6C 0x6C 0x6F. If the file holds Café, the stream sees four characters but five bytes, because the é becomes two bytes (0xC3 0xA9) under UTF-8. A raw stream has no concept of "give me the next character" or "give me the next line"; at that layer there are no characters and no lines. There are only bytes.
Text wrappers add the missing layer. A StreamReader reads a chunk of bytes from the underlying stream, runs them through an Encoding (UTF-8 by default), and returns char values, string values, or whole lines. A StreamWriter does the inverse: it accepts strings, encodes them to bytes, and writes those bytes to the underlying stream. The wrappers also buffer internally, so a ReadLine call doesn't trigger a separate disk read for every byte.
The diagram shows the layered model. Code talks to the reader or writer in characters and strings. The reader or writer talks to the encoding in characters on one side and bytes on the other. The encoding talks to the underlying stream in bytes. The underlying stream touches disk, network, or memory. Every text I/O operation in .NET follows this shape, even when the wrappers hide it behind convenience methods like File.ReadAllText.
The split also explains why these classes exist as a separate layer. The byte stream knows how to read and write. The encoding knows how to translate. The text wrapper knows how to buffer and find boundaries like end-of-line. Each piece is replaceable: swap the encoding without changing the stream, swap the stream without changing the wrapper, or use the wrapper over a memory stream during testing instead of a file stream in production.
StreamReader exposes a small set of methods that cover almost every text-reading pattern. The four common ones are ReadLine, ReadToEnd, Read (single character), and the async counterparts ReadLineAsync and ReadToEndAsync. Peek and EndOfStream are available for cases that need to look at what's coming next without consuming it.
ReadLine reads bytes from the underlying stream, decodes them, and returns the next line as a string (without the trailing line terminator). It returns null when the stream is exhausted, which is the standard signal for end-of-file in a line-by-line loop. The line terminator can be \r, \n, or \r\n; the reader handles all three transparently.
The using var declaration disposes the reader (and, by default, the underlying file stream) at the end of the enclosing scope. The null check on line is the loop's exit condition: once ReadLine has run out of input, it returns null and the loop ends.
ReadToEnd reads everything from the current position to the end of the stream and returns it as a single string. Use it when the file is small and the whole contents are needed in one shot. Avoid it when the file is large, because the entire content has to fit in a single string in memory.
Read() with no arguments returns the next character as an int, or -1 at end-of-stream. The return type is int rather than char to fit the -1 sentinel value, which would not fit in a char. Cast to char to use the result as text. The Read(char[] buffer, int index, int count) overload reads multiple characters into a buffer in one call, useful for fixed-size chunks instead of lines.
Peek looks at the next character without advancing the position. It also returns -1 at end-of-stream. It's useful when the decision to keep reading depends on the next character without consuming it. EndOfStream is a boolean property that indicates whether the next read would return end-of-stream. Both require the underlying stream to support seeking or buffering ahead, which FileStream does.
ReadToEnd allocates a string the size of the entire file. On a 2 GB log file, that's 4 GB of memory for the string (because .NET strings are UTF-16, two bytes per character) plus the read buffer. For anything larger than a few megabytes, switch to ReadLine in a loop or read fixed-size buffers with Read(char[], ...).
The async variants ReadLineAsync and ReadToEndAsync have the same behavior but return Task<string?> and Task<string> respectively. They release the calling thread during the disk wait, which matters in any program that needs to scale concurrent file or network reads. Use them when the underlying stream represents network or disk I/O, and use the synchronous versions only for in-memory streams or short scripts where scalability isn't a concern.
StreamWriter is the mirror image of StreamReader. The two common methods are Write (writes a string or value with no trailing newline) and WriteLine (writes the value plus a line terminator). Both have many overloads accepting strings, characters, integers, doubles, objects (calling ToString on them), and string interpolation results. The async counterparts WriteAsync and WriteLineAsync return tasks and should be used for disk and network streams.
The file cart.txt ends up with five lines of text. The Write(3) call converts the integer to its string representation, and the call to WriteLine() with no arguments writes only the line terminator. Reading the file back confirms what was written.
Two important details about how StreamWriter moves bytes to disk. First, it buffers internally (the default buffer is around 1 KB of characters). Calling Write or WriteLine places characters in the buffer. The bytes only reach the underlying stream when the buffer fills up, when Flush is called, or when the writer is disposed. Second, the underlying stream itself usually has a buffer too (FileStream has its own buffer by default), so even after a flush from the writer, the bytes may still be sitting in the file stream's buffer waiting to be written to disk.
That layered buffering is normally invisible. It becomes visible the moment the program crashes or exits without disposing the writer.
What's wrong with this code?
If the process exits before writer.Dispose() runs, the buffered line never reaches disk. The file may end up empty even though WriteLine returned without error. The fix is the standard using pattern, which calls Dispose automatically; disposal flushes the buffer.
Fix:
Flush forces the writer to push its buffered characters down to the underlying stream immediately, without waiting for the buffer to fill or for disposal. Use it when data must be durable before continuing, for example in a long-running loop where each record matters and the program might be terminated at any time. FlushAsync is available for async code.
AutoFlush is a property that, when set to true, makes the writer flush after every Write or WriteLine call. That guarantee comes at a cost: each write triggers a downstream call into the underlying stream, which adds overhead. The default (AutoFlush = false) is right for almost all batch writing. Turn it on only when real-time visibility is needed, such as writing to a console or a log that another process tails.
AutoFlush = true removes the writer's batching, so a tight loop writing thousands of small lines pays the per-call overhead each time. For batch logging, leave AutoFlush off and call Flush at meaningful checkpoints, or rely on disposal at the end.
The async versions WriteAsync and WriteLineAsync work the same way as the sync versions but yield the thread during the underlying I/O. FlushAsync exists for the same reason. Use the async forms when the writer wraps a network stream or any disk-bound FileStream, especially in server code.
The await using form is the async equivalent of using. It calls DisposeAsync instead of Dispose at the end of the scope, so any flush done as part of disposal also happens asynchronously. Use it whenever WriteAsync or WriteLineAsync appears in the same block.
An Encoding is the rule that maps between Unicode characters and a sequence of bytes. .NET ships with several built-in encodings exposed as static properties on System.Text.Encoding. The four that come up most often are UTF8, ASCII, Unicode (which is UTF-16), and Latin1 (also called ISO-8859-1).
| Encoding | Bytes per char | Range covered | Where it appears |
|---|---|---|---|
Encoding.UTF8 | 1 to 4 | All Unicode | Default for .NET text I/O, web, JSON, modern files |
Encoding.ASCII | 1 | 0 to 127 | Legacy systems, simple log formats, network protocols |
Encoding.Unicode | 2 (or 4 with surrogates) | All Unicode | Windows internals, legacy Windows files, in-memory string |
Encoding.Latin1 | 1 | 0 to 255 | Older European text files, some HTTP headers |
Encoding.UTF32 | 4 | All Unicode | Rare, large files, mostly academic |
UTF-8 is the default. Without an explicit encoding when constructing a StreamReader or StreamWriter, UTF-8 is used. This matches almost every modern data format (JSON, XML when it doesn't say otherwise, CSV when produced by modern tools, HTTP bodies). For new files, use UTF-8 unless there's a specific reason to pick something else.
The first three bytes (EF BB BF) are the UTF-8 byte order mark (BOM), a marker some tools use to detect that the file is UTF-8. After the BOM come the bytes for Café au lait followed by \r\n (0D 0A). The é is two bytes (C3 A9) because it's outside the ASCII range.
The BOM is a useful default in some contexts and unwanted in others. Encoding.UTF8 includes the BOM. To write UTF-8 without the BOM (which is what most Unix tools, web servers, and JSON files expect), construct a fresh UTF8Encoding:
Same content, no BOM. This is the safer default for files consumed by other tools.
When the encoding doesn't match, characters get garbled. Reading a UTF-8 file with Encoding.ASCII will replace every non-ASCII byte with a question mark, because ASCII has no representation for those bytes.
What's wrong with this code?
The file holds five bytes (43 61 66 C3 A9), but the reader is told to interpret them as ASCII. ASCII has no mapping for the two bytes that make up é, so each one decodes to ?. The output is Caf?? instead of Café.
Fix: match the encoding used to write the file, or rely on the UTF-8 default.
StreamReader can also try to detect the encoding from a BOM at the start of the file. The constructor overload new StreamReader(stream, detectEncodingFromByteOrderMarks: true) does exactly that. It only works when the file actually starts with a BOM, and only for the small set of encodings BOMs cover (UTF-8, UTF-16 LE, UTF-16 BE, UTF-32). For files without a BOM, the encoding has to be known out of band; there is no reliable way to detect it from the bytes alone.
The picture sums up the encoding rule. The bytes on disk are the same in both branches. What changes is the lens the reader uses to interpret them. With the wrong lens, the bytes still come through, but the characters they represent are wrong. This is why an explicit encoding choice matters when the file's source isn't your own code.
StreamReader and StreamWriter each have a family of constructors that fall into two groups: ones that take an existing Stream, and ones that take a file path as a shortcut.
The stream-taking constructors are the most flexible. They can wrap any stream, not just a file: a MemoryStream for tests, a NetworkStream for sockets, a GZipStream for compressed files, or a CryptoStream for encrypted ones. The common form is new StreamReader(stream, encoding, detectEncodingFromByteOrderMarks, bufferSize, leaveOpen). Only stream is required; everything else has a default.
The leaveOpen: true parameter is important here: without it, disposing the writer would also dispose the underlying MemoryStream, and the subsequent read would throw ObjectDisposedException. The disposal section below covers this in more detail.
The path-taking constructors are convenience wrappers. new StreamReader(path) is roughly equivalent to new StreamReader(File.OpenRead(path)), and new StreamWriter(path) is roughly equivalent to new StreamWriter(File.Create(path)). They handle the file-stream creation and dispose the file stream when the reader or writer is disposed.
The StreamWriter path constructor has an append overload: new StreamWriter(path, append: true) opens the file for appending instead of truncating it. This matches the FileMode.Append behavior.
File has three more shortcut helpers that return readers and writers preconfigured for text. These are the shortest way to get a working text I/O setup.
| Helper | Returns | Equivalent |
|---|---|---|
File.OpenText(path) | StreamReader | new StreamReader(path) |
File.CreateText(path) | StreamWriter | new StreamWriter(path) (truncates) |
File.AppendText(path) | StreamWriter | new StreamWriter(path, append: true) |
The buffer size parameter on the stream constructor controls how many bytes the underlying read or write operation grabs at once. The default is 1024 characters for the reader and writer. Larger buffers reduce the number of round trips to the underlying stream at the cost of more memory per writer or reader. For sequential reads of large files, a buffer of 4 KB to 64 KB is typically optimal. For tiny writes to many separate files, the default is fine. There is rarely a reason to go below the default.
Constructing a StreamReader or StreamWriter is cheap, but doing it inside a tight loop on the same file (open, read one line, close, repeat) is not. Each open opens the underlying file handle, allocates buffers, and on disposal flushes and closes. Open the reader or writer once outside the loop, then reuse it.
StreamReader exists, instead of a single File.ReadAllText call for everything, because some files don't fit in memory. A 10 GB application log can't be loaded into a single string. A 50 GB telemetry archive can't be read with ReadToEnd. The pattern for large files is line-by-line streaming, where the program holds at most one line in memory at a time.
Memory usage stays low. The reader keeps an internal buffer of around 1 KB at a time. The program holds the current line and the running counter. Total memory is well under a megabyte regardless of whether the file is 10 KB or 10 GB.
The contrast with ReadToEnd is dramatic.
File.ReadAllText is internally a ReadToEnd call. On a 10 GB ASCII log, that's 20 GB of memory for the resulting string, because .NET strings are UTF-16. Even on machines that can spare the memory, the allocation alone takes seconds and pressures the garbage collector. The line-by-line version finishes in the time it takes to scan the file once.
The same pattern works for any kind of line-oriented format: CSV, log files, JSON Lines (one JSON object per line), TSV, or simple newline-separated records. For non-line-oriented formats, switch to Read(char[], ...) and process fixed-size chunks instead.
The chunk-based approach fits when the file format isn't naturally split by lines, for example a single huge XML or JSON document. In those cases pair the stream reader with a streaming parser like XmlReader or Utf8JsonReader that consumes the chunks incrementally.
For asynchronous streaming, use ReadLineAsync in the loop. Combined with await using for the reader, the entire pipeline yields the thread during disk reads.
In a server processing many large files concurrently, the async version frees the worker thread during each disk wait, which is the scalability win async provides.
The disposal rules for text wrappers can be non-obvious. By default, disposing a StreamReader or StreamWriter also disposes the underlying stream. This fits when the wrapper owns the file. It doesn't fit when the wrapper is one of several layers over the same stream, or when the underlying stream needs to outlive the wrapper.
The default behavior:
This is convenient for the common case (open file, read it, close everything) but it breaks when the stream needs more work after the reader is done. The fix is the leaveOpen parameter on the stream-taking constructors.
Both wrappers leave the underlying MemoryStream open when they dispose. The outer using var memory = new MemoryStream() is what eventually disposes the stream when the enclosing scope ends.
The leaveOpen pattern is essential when chaining streams. A common pipeline is something like FileStream → GZipStream → StreamReader. Each wrapper in the chain has to make a choice about whether to dispose the layer below it. If every wrapper closes its inner layer by default, disposing only the outermost one is enough. If the file stream needs to stay open after reading (perhaps to seek back and write to it), use leaveOpen: true on the wrappers.
The diagram captures the rule. The wrapper's Dispose always runs. What it does to the inner stream depends on the constructor flag. With the default, the inner stream is closed too. With leaveOpen: true, the wrapper releases its own buffers but leaves the stream alone.
Disposal also flushes the writer's buffer. That's what makes using important for StreamWriter: forgetting it means losing whatever was still in the buffer at the time the program ended. The async version DisposeAsync (used via await using) does the same flush asynchronously, which matters when the underlying stream is network or remote disk.
What's wrong with this code?
When the writer is disposed, it also disposes the MemoryStream because leaveOpen defaults to false. The next line tries to seek the disposed stream to position 0 and throws ObjectDisposedException.
Fix: pass leaveOpen: true so the writer doesn't take the stream down with it.
This lesson covers the text wrappers. Lesson 05 covers BinaryReader and BinaryWriter, the byte-oriented siblings for binary file formats. Lessons 07 and 08 cover serialization, where text and binary I/O get pulled together with format-specific encoders for JSON and XML.
10 quizzes