Stream Classes

Vaibhav • September 10, 2025

In the previous article, we explored how to read and write binary files using FileStream, BinaryReader, and BinaryWriter. These classes gave us low-level control over byte-level operations. But behind the scenes, nearly all file I/O in .NET is built on a powerful abstraction: streams. In this article, we’ll dive into the stream class hierarchy, understand how streams work, and learn how to use them effectively for both text and binary data.

What Is a Stream?

A stream is an abstraction for reading and writing a sequence of bytes. It could represent a file, a memory buffer, a network socket, or even a compressed archive. The key idea is that you don’t need to know the underlying source - you just read or write bytes through a consistent interface.

Streams are part of the System.IO namespace and are designed to be composable. You can wrap one stream inside another to add functionality like buffering, compression, or encryption.

All stream classes inherit from the abstract base class Stream. This class defines the core methods and properties for reading, writing, seeking, and flushing data.

Common Stream Types

.NET provides several built-in stream types. Here are the most commonly used ones:

  • FileStream - reads and writes files
  • MemoryStream - reads and writes to memory
  • BufferedStream - adds buffering to another stream
  • NetworkStream - reads and writes over a network
  • CryptoStream - adds encryption/decryption
  • GZipStream - compresses or decompresses data

Each of these streams serves a different purpose, but they all share the same interface defined by Stream.

Basic Stream Operations

The Stream class defines several key methods:

  • Read(byte[] buffer, int offset, int count)
  • Write(byte[] buffer, int offset, int count)
  • Seek(long offset, SeekOrigin origin)
  • Flush()
  • Close() (via Dispose())

Let’s look at a simple example using FileStream:

using FileStream fs = new FileStream("data.bin", FileMode.Create);

byte[] buffer = { 1, 2, 3, 4 };
fs.Write(buffer, 0, buffer.Length);
fs.Flush();

This creates a file and writes four bytes to it. Flush ensures that all buffered data is written to disk.

Reading with Streams

Reading from a stream involves filling a buffer with bytes. You can read in chunks or byte-by-byte:

using FileStream fs = new FileStream("data.bin", FileMode.Open);

byte[] buffer = new byte[4];
int bytesRead = fs.Read(buffer, 0, buffer.Length);

Console.WriteLine($"Read {bytesRead} bytes");

This reads up to 4 bytes from the file. The actual number of bytes read may be less if the file is smaller.

Using MemoryStream

MemoryStream is useful when you want to work with data in memory instead of on disk. It’s often used for temporary buffers or when working with APIs that expect a stream.

byte[] data = { 10, 20, 30 };

using MemoryStream ms = new MemoryStream(data);
int b = ms.ReadByte();
Console.WriteLine($"First byte: {b}");

This reads the first byte from the memory stream. You can also write to a MemoryStream and retrieve the data using ToArray().

BufferedStream for Performance

BufferedStream wraps another stream and adds a buffer to reduce the number of I/O operations. This improves performance when reading or writing small chunks repeatedly.

using FileStream fs = new FileStream("data.bin", FileMode.Open);
using BufferedStream bs = new BufferedStream(fs);

byte[] buffer = new byte[1024];
int bytesRead = bs.Read(buffer, 0, buffer.Length);

This reads data through a buffer, which reduces disk access and improves speed.

Use BufferedStream when reading or writing small amounts of data frequently. It reduces overhead and improves performance.

StreamReader and StreamWriter

For text files, you’ll often use StreamReader and StreamWriter. These classes wrap a stream and handle character encoding, line breaks, and buffering.

// Writing text
using FileStream fs = new FileStream("text.txt", FileMode.Create);
using StreamWriter writer = new StreamWriter(fs);

writer.WriteLine("Hello, Stream!");
// Reading text
using FileStream fs = new FileStream("text.txt", FileMode.Open);
using StreamReader reader = new StreamReader(fs);

string line = reader.ReadLine();
Console.WriteLine(line);

These classes automatically handle encoding and buffering, making them ideal for working with text files.

Composing Streams

One of the most powerful features of streams is composition. You can wrap streams inside other streams to add functionality. For example, you can compress data using GZipStream:

using FileStream fs = new FileStream("compressed.gz", FileMode.Create);
using GZipStream gzip = new GZipStream(fs, CompressionMode.Compress);
using StreamWriter writer = new StreamWriter(gzip);

writer.WriteLine("This is compressed text.");

This writes compressed text to a file. You can read it back using the same stream composition in reverse.

You can chain multiple streams together - for example, a BufferedStream over a CryptoStream over a FileStream. This lets you build powerful I/O pipelines.

Checking Stream Capabilities

Not all streams support all operations. You can check a stream’s capabilities using these properties:

  • CanRead
  • CanWrite
  • CanSeek
using FileStream fs = new FileStream("data.bin", FileMode.Open);

Console.WriteLine($"Readable: {fs.CanRead}");
Console.WriteLine($"Writable: {fs.CanWrite}");
Console.WriteLine($"Seekable: {fs.CanSeek}");

This helps you avoid runtime errors when using streams in generic code.

Disposing Streams Properly

Streams often hold unmanaged resources like file handles or network sockets. Always dispose them properly using using blocks or Dispose():

using FileStream fs = new FileStream("data.bin", FileMode.Open);
// Stream is automatically closed when the block ends

This ensures that resources are released promptly and avoids file locking or memory leaks.

Asynchronous Streams

Streams also support asynchronous operations for non-blocking I/O. This is useful in UI apps or servers where responsiveness matters.

using FileStream fs = new FileStream("data.bin", FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);

byte[] buffer = new byte[1024];
int bytesRead = await fs.ReadAsync(buffer, 0, buffer.Length);

This reads data asynchronously. You can also use WriteAsync and FlushAsync.

Use asynchronous streams in applications that require high responsiveness or handle many concurrent I/O operations.

Summary

Streams are the foundation of all I/O in .NET. They provide a unified interface for reading and writing data, whether it’s from a file, memory, network, or another source. In this article, you learned about the Stream class hierarchy, how to use FileStream, MemoryStream, BufferedStream, and how to compose streams for advanced functionality. You also saw how to use StreamReader and StreamWriter for text, and how to handle asynchronous operations. In the next article, we’ll explore path manipulation - how to work with file and directory paths safely and portably across platforms.