A Stream is an abstraction that represents a sequence of bytes that we can read or write without caring where they come from or where they go.
You can think of a stream as a pipeline connecting your code to a data source or destination. Bytes enter from one end and leave from the other, without needing to load the entire content into memory.
This is especially important if you need to process, for example, a 10 GB video. You can’t load it entirely into RAM (byte[]), but you can read it bit by bit as it flows.
The base class System.IO.Stream
Stream is an abstract class, so you can’t do new Stream(). We use concrete implementations like FileStream or MemoryStream, although we can treat them through the base type when we only need the common operations.
Its main operations are:
Read: reads bytes from the stream and copies them to a buffer.Write: writes the bytes from a buffer into the stream.Seek: moves the current position if the stream supports random access.Flush: sends pending data in the buffer to the destination.
A stream handles bytes (byte). It doesn’t know what a letter A or an integer 100 is. To work with text or data types, we use classes like StreamReader, BinaryReader, or StreamWriter on top of the stream.
Common stream types
Depending on where the data resides, we use one implementation or another.
FileStream, data on disk
FileStream connects the code to a file on the system. It is one of the classes used internally by System.IO.File.
// Open a stream to read a file
using (FileStream fs = new FileStream("video.mp4", FileMode.Open))
{
Console.WriteLine($"File size: {fs.Length} bytes");
}MemoryStream, data in memory
MemoryStream creates a stream that lives inside RAM. It is useful when a function expects a Stream, but you already have the data in a byte array and don’t want to go through the disk.
byte[] data = { 1, 2, 3, 4, 5 };
using (MemoryStream ms = new MemoryStream(data))
{
int firstByte = ms.ReadByte();
}NetworkStream, network data
NetworkStream is used in TCP communications. Here the concept of a stream is literal: data arrives over the network bit by bit.
Note that a network stream usually does not allow using Seek, because we cannot go back to receive bytes that have already passed.
The buffer
Reading byte by byte is very inefficient. Each request can involve additional work for the operating system or the input device.
That’s why we read data blocks using a buffer, a temporary space in memory that reduces the number of operations:
using (FileStream fs = new FileStream("data.bin", FileMode.Open))
{
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0)
{
// Process only the valid part of the buffer
ProcessData(buffer, bytesRead);
}
}If you only need to copy one complete stream to another, CopyTo already implements this loop and manages the buffer for you.
using (var source = new FileStream("in.dat", FileMode.Open))
using (var destination = new FileStream("out.dat", FileMode.Create))
{
source.CopyTo(destination);
}Chaining streams
Streams can be wrapped to add transformations. This design allows combining responsibilities without each class knowing the final destination.
For example, we can write compressed text to a file using three layers:
FileStreamwrites to the file.GZipStreamcompresses the bytes.StreamWriterconverts text to bytes.
using System.IO.Compression;
using (FileStream fs = new FileStream("archive.gz", FileMode.Create))
using (GZipStream compressor = new GZipStream(fs, CompressionLevel.Optimal))
using (StreamWriter writer = new StreamWriter(compressor))
{
writer.WriteLine("Hello compressed world");
}StreamWriter doesn’t care if it writes to a file, memory, or a compressed connection. It only needs to receive another writable stream.
Seek and Position
A stream has an internal position accessible via Position. When you read or write bytes, that position advances.
If the stream supports seeking (you can check with CanSeek), you can go back or jump to a specific position:
using (MemoryStream ms = new MemoryStream())
{
ms.WriteByte(65); // Writes 'A' and advances to position 1
ms.Position = 0; // Go back to the beginning
int read = ms.ReadByte(); // Reads 65 ('A')
}Dispose and Flush
Streams may keep files, sockets, and other system resources open. Therefore, you must always release them when you’re done.
The usual way is to use a using declaration or block, which calls Dispose() even if an exception occurs. Closing a stream also flushes its pending buffers.
Flush() allows requesting that flush without closing the stream. However, you should not assume it alone guarantees that data has reached physical storage, as other caching levels may exist.