AlgoMaster Logo

Streams (FileStream, MemoryStream)

Medium Priority23 min readUpdated June 6, 2026

A stream is C#'s general-purpose abstraction for a sequence of bytes you can read from, write to, or both. The same model works for files on disk, blocks of memory, network sockets, and pipes between processes, which is why so much of the .NET I/O surface is built on the Stream base class. This lesson covers the abstract Stream API itself, the two concrete types you'll meet first (FileStream and MemoryStream), how the OS file handle relates to the managed stream object, the buffering and disposal rules that decide whether your data actually reaches disk, and when to use async I/O versus plain synchronous calls.

The Stream Abstraction

Every concrete stream in .NET, whether it's reading bytes off the network or writing them to a file, derives from System.IO.Stream. That base class defines a small core of operations: read some bytes, write some bytes, move to a different position, find out how big the underlying source is, and flush any buffered data. Concrete subclasses fill in the implementation for their specific backing store.

The core members to know on day one:

MemberWhat it does
int Read(byte[] buffer, int offset, int count)Reads up to count bytes into buffer starting at offset. Returns the number actually read, or 0 at end of stream.
void Write(byte[] buffer, int offset, int count)Writes count bytes from buffer starting at offset.
Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken)Async version of Read. Returns a task that completes with the byte count.
Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken)Async version of Write.
long Seek(long offset, SeekOrigin origin)Moves the read/write cursor to a new position.
long Position { get; set; }The current cursor position in bytes from the start.
long Length { get; }The total size of the stream in bytes (when known).
void Flush() / Task FlushAsync()Pushes any buffered data to the underlying device.
bool CanRead, bool CanWrite, bool CanSeekCapability flags the concrete subclass sets.

The capability flags exist because not every stream supports every operation. A NetworkStream over TCP can be read and written but never seeked, because there's no way to "rewind" bytes that have already left the wire. A read-only FileStream opened for FileAccess.Read returns false from CanWrite. Code that wants to handle any stream defensively should check the flag before calling the operation.

A short example that uses the core API directly. Imagine writing a small product catalog snippet to memory and then reading it back.

Two details are worth pausing on. First, after Write, the cursor sits at the end of what was written, which is why Position is 44. To read the bytes back you have to move the cursor back to the start, either with stream.Position = 0 or stream.Seek(0, SeekOrigin.Begin). Second, Read returns the count of bytes it actually placed in the buffer. For a MemoryStream that count is normally what you asked for, but for a FileStream or NetworkStream you can get a smaller number on a single call and need to loop until you've collected everything you want.

The Read return value is the part beginners get wrong most often. The contract is: read up to count bytes, return how many you got, return 0 only at end of stream. If you want exactly N bytes, loop:

In .NET 7 and later there's a built-in Stream.ReadExactly that does the same thing, so you don't usually need to write the helper yourself. The point of showing it is that the loop is the contract: a single Read call is allowed to return less than you asked for, and any code reading from a stream over the network or from a slow disk has to handle that.

FileStream and the OS File Handle

FileStream is the concrete Stream that talks to a file on disk. When you construct one, the runtime calls into the operating system to open the file and gets back a handle, which is a small integer the OS uses to identify the open file in its tables. The FileStream object wraps that handle along with a managed byte buffer that smooths over the cost of small reads and writes.

The most useful constructor takes four arguments:

There are longer overloads that let you pass a buffer size and an useAsync flag, plus a newer FileStreamOptions overload that bundles all the parameters into one object. The four-argument form makes you think about each policy decision explicitly.

A few things happen in that snippet that aren't obvious from the syntax. The FileMode.Create argument tells the OS "create this file, replacing any existing file at the same path." The FileAccess.Write argument means the handle is opened in write-only mode, so any attempt to call Read on the stream will throw NotSupportedException. The FileShare.Read argument tells the OS "while I have this file open, other processes are allowed to open it for reading but not for writing." And the using block ensures Dispose is called on the stream when the block exits, which closes the OS handle and flushes any buffered bytes to disk. Skip that and you might keep the file locked until garbage collection runs, which can be a long time.

The relationship between the managed FileStream and the OS file handle is one-to-one. As long as the FileStream is alive and undisposed, the OS counts the file as open by your process. That's why disposal matters so much for files: an undisposed stream holds a kernel-level resource, and on Windows it can prevent other processes from opening the file at all (depending on the FileShare you passed when opening).

The diagram shows the layering. Your code calls Read or Write on the FileStream. The FileStream services those calls out of its internal buffer when it can, and when the buffer is empty (for reads) or full (for writes) it makes a system call through the OS file handle to fetch or flush a larger block from disk. The disk only sees these larger block transfers, not your individual writes, which is what makes a stream of small writes acceptable in performance terms.

The internal buffer defaults to 4096 bytes. That number is chosen because it's roughly the size of a memory page on most operating systems and lines up well with how disks transfer data. If you write 50 bytes at a time in a loop, the FileStream accumulates them in its 4 KB buffer and only issues a syscall once every 80 or so writes. Reading works the same way in reverse: a small Read call may pull a 4 KB block from disk into the buffer and return just the slice you asked for, with the remaining bytes ready for the next call.

You can change the buffer size with one of the longer constructors:

The useAsync flag is the second tuning knob. When it's true, the underlying handle is opened in OS-level overlapped (Windows) or non-blocking (Unix) mode, which is what makes ReadAsync and WriteAsync actually use the OS's async I/O machinery instead of just queuing the work to a thread pool thread. If you plan to use the async methods on the stream, set useAsync: true. If you plan to use only the synchronous methods, set it to false; opening in async mode adds a small overhead per call.

Setting useAsync: true on a FileStream you only ever use synchronously adds per-call overhead for no gain. Setting useAsync: false and then calling ReadAsync works, but the runtime simulates async by handing the work to the thread pool, which removes the scalability benefit you wanted from async in the first place. Match the flag to how you actually use the stream.

FileMode, FileAccess, FileShare

The three enums you pass to the FileStream constructor look similar but answer different questions. FileMode is "how should the OS open the file?", FileAccess is "what am I allowed to do with the handle?", and FileShare is "what are other processes allowed to do while I have it open?".

FileMode has six values, and the differences mostly come down to how they handle a file that already exists at the path:

FileModeIf file existsIf file does not exist
CreateNewThrows IOExceptionCreates the file
CreateTruncates to zero bytes and overwritesCreates the file
OpenOpens the existing fileThrows FileNotFoundException
OpenOrCreateOpens the existing fileCreates the file
TruncateOpens and truncates to zero bytesThrows FileNotFoundException
AppendOpens and seeks to end (write-only)Creates the file

Append is special in two ways. First, it requires FileAccess.Write (or FileAccess.ReadWrite is rejected with an exception). Second, it positions the cursor at the end of the file on open, so anything you write goes after the existing contents. It's the right mode for a log file you want to keep growing across program runs.

Create versus CreateNew is the question of "do you want to clobber an existing file?". CreateNew is safer for one-shot output where overwriting would lose data. Create is right when you want to replace the file no matter what, like exporting a fresh catalog dump every night.

FileAccess is straightforward:

FileAccessAllows
ReadRead, ReadAsync, Seek (when supported), reading Length
WriteWrite, WriteAsync, setting Length, Flush
ReadWriteAll of the above

Asking for ReadWrite when you only need Read isn't an error, but it can cause unexpected sharing conflicts. Other processes that wanted to open the file for writing might be blocked when they wouldn't have been if you'd asked for read-only access.

FileShare controls what other processes (and other handles in your own process) can do to the same file while your handle is open:

FileShareOther handles can
NoneNothing. Other opens fail.
ReadOpen for reading only.
WriteOpen for writing only.
ReadWriteOpen for reading or writing.
DeleteDelete or rename the file. (Combinable with the others.)

These flags are bit flags, so FileShare.Read | FileShare.Delete means "others can read this and even delete it while I have it open." On Windows, file sharing semantics are strict: if process A opens a file with FileShare.None and process B tries to open it at all, process B's open call fails. On Unix-like systems the OS is more permissive about sharing in general, but .NET still tries to enforce the same rules at the runtime level for cross-platform consistency.

A common log-writing pattern uses FileMode.Append with FileShare.Read so other tools can tail the log while it's being written:

Flush here pushes the buffered bytes from the managed buffer through the OS handle. Without it, a small write may sit in the 4 KB buffer waiting for more data, and a tail -f reader watching the file wouldn't see the new line until the buffer fills or the stream is disposed.

MemoryStream

MemoryStream is a Stream whose backing store is a managed byte array on the heap instead of a file or socket. It implements the same Read, Write, Seek, Position, and Length API, so anything that takes a Stream will accept a MemoryStream without modification. That's the main reason it exists.

The most common use cases:

  • Building a payload in memory before sending it somewhere. For example, serializing a list of products to JSON in a MemoryStream, then handing that stream to an HTTP client to upload.
  • Receiving a payload in memory from somewhere else. For example, downloading a small file into a MemoryStream so you can read it multiple times (network streams aren't seekable, memory streams are).
  • Unit testing code that takes a `Stream`. Instead of writing real files to disk for tests, hand the production code a MemoryStream pre-loaded with bytes.
  • Transforming bytes through a chain of streams without touching the disk.

A small example that builds a CSV export in memory and then "sends" it (here, just prints it):

Two methods on MemoryStream look similar and trip up new users: ToArray and GetBuffer. They are not the same.

ToArray allocates a new byte[] exactly the size of the stream's logical content (its Length) and copies the data into it. Safe to use, gives you a clean array of just your bytes, but allocates and copies.

GetBuffer returns the underlying buffer the MemoryStream is using internally. That buffer is usually larger than Length because MemoryStream grows its buffer in powers of two, so a stream containing 73 bytes might be backed by a 256-byte array. The bytes after Length are uninitialized garbage from the perspective of your data. GetBuffer is faster (no allocation, no copy) but you must respect Length and never read past it. By default GetBuffer throws if the stream was constructed with a non-publicly-visible buffer, so you usually pair it with the MemoryStream(int capacity) or default constructor.

MethodAllocates?Copies?Returns
ToArray()Yes (new array sized to Length)YesExact-sized array, safe to use
GetBuffer()NoNoInternal buffer, often larger than Length

ToArray allocates and copies every byte of the stream. For a 100 MB MemoryStream, that's a 100 MB allocation and a 100 MB copy. Use GetBuffer (paired with Length) when you control both ends of the code and you know you won't read past the logical end.

A second MemoryStream constructor wraps an existing byte[] instead of allocating a new buffer. This is the pattern for testing or for parsing bytes you already have:

When you wrap an existing array, the stream is by default fixed-size and not expandable. Writing past the end throws NotSupportedException. There are constructor overloads that let you make the wrapped buffer writable or expandable, but the more common pattern is "I have these bytes, let me read them through a Stream API."

CopyTo and CopyToAsync

Copying bytes from one stream to another is so common that Stream has built-in helpers for it: CopyTo for the synchronous version and CopyToAsync for the async version. They handle the buffer management and the read loop for you.

The default buffer size for the copy is 81920 bytes (80 KB), which the BCL chose as a reasonable trade-off between syscall overhead and memory pressure. There's an overload that takes a custom buffer size if your scenario justifies tuning it.

CopyToAsync is the appropriate choice when either of the streams is backed by something slow (network, large file). The thread is released during the underlying I/O waits, so a service that copies many files can use a small thread pool effectively.

HttpClient.GetStreamAsync returns a Stream that reads bytes off the network connection as you pull them. CopyToAsync then pumps those bytes straight into the file stream without loading the whole image into memory first. This is the right pattern for any "stream the response to disk" scenario, including downloading large CSV exports, syncing media files, or persisting a database backup pulled from a remote service.

The mental model is shown in the next diagram. The copy loop reads a buffer-sized chunk from the source, writes it to the destination, and repeats until the source returns 0 from Read.

The loop runs until the source stream signals end-of-stream. For a FileStream that's "the file's Length has been read." For a NetworkStream it's "the remote side closed the connection." The same CopyTo call works for both because the contract of Read returning 0 is the same in both cases.

Disposal: Why using Matters for Streams

Stream implements IDisposable, and not disposing a stream has real consequences. For a FileStream, two things happen at disposal: the OS file handle is closed (releasing the kernel resource and the file lock), and any data sitting in the managed buffer is flushed to disk. Skip the disposal and one or both of those steps doesn't happen.

The most common ways to ensure disposal:

If you forget both forms and rely on the garbage collector to eventually finalize the stream, two failure modes show up. First, the file stays "open" from the OS's perspective for an unpredictable amount of time, because the GC doesn't run on demand. On Windows that means another process or even your own program will fail with "The process cannot access the file because it is being used by another process." Second, any bytes still in the managed buffer at the moment the program crashes or exits hard are never flushed, so you lose the tail of your data.

The method opens a file handle every time it's called and never closes it. After a few calls the OS sees several open handles to the same file held by your process. Eventually the runtime's finalizer thread will catch up and close them, but on Windows the file is locked the entire time, and on heavy use you can hit the per-process handle limit.

Fix:

The using declaration disposes the stream when the method returns, which closes the OS handle and flushes any buffered data. The general rule applies here just as strictly: anything that owns an OS resource must be disposed, ideally with using.

MemoryStream also implements IDisposable, but the cost of forgetting is much smaller. There's no OS handle to release, just a managed byte array that the GC will eventually collect. Disposing it eagerly is still good practice because it prevents anyone from accidentally using the stream after you intended it to be done, but a forgotten MemoryStream won't lock a file or leak a kernel resource.

Async I/O: When It Helps, When It Doesn't

Stream exposes async versions of every blocking operation: ReadAsync, WriteAsync, FlushAsync, CopyToAsync. The same rule from the async basics lesson applies here: async helps when the operation involves a meaningful wait on something outside the CPU.

For files, the picture has more nuance than for network calls. A small file read (a few KB) from a fast SSD finishes in microseconds. The overhead of the async state machine and the Task allocation can be larger than the time saved. A large file read (megabytes or more) or a read from a slow disk or network share can easily take milliseconds, which is the regime where async pays off the same way it does for network calls.

A useful rule of thumb:

ScenarioUse sync or async?
Reading a small config file (under 4 KB) at startupSync is fine
Streaming a large CSV import (tens of MB)Async
Writing a single short log lineSync is fine; the buffer absorbs the cost
Writing many large entries to a log fileAsync
Copying bytes from an HTTP response to diskAsync
Building a 1 KB JSON payload in a MemoryStreamSync (MemoryStream async methods just call the sync ones synchronously and wrap the result; there's no real I/O)
In a desktop UI app, any file operation during user interactionAsync, to keep the UI thread responsive

The last row of the table is the same UI-responsiveness argument from lesson 18. Even a "small" file read that takes 30 ms can cause a visible UI freeze, so in WPF, WinUI, and MAUI apps, async I/O is the default for anything touching the disk.

The detail about MemoryStream is worth pausing on. MemoryStream.ReadAsync and MemoryStream.WriteAsync exist, but they have no real async work to do, the data is already in memory. The implementation completes the work synchronously and returns an already-completed task. That makes them safe to use polymorphically (when your code accepts any Stream and might get a memory or file or network stream) but offers no scalability benefit on the memory case itself.

A side-by-side example to make the async file I/O pattern concrete. Imagine writing 1 MB of order data to disk.

The useAsync: true argument on the constructor matters here. Without it, WriteAsync falls back to running the sync write on a thread pool thread, which is the worst of both worlds: you pay async overhead and still consume a thread for the duration. With useAsync: true, the OS handles the write through its native overlapped/non-blocking machinery and your thread is free during the wait.

Calling async methods on a stream opened with useAsync: false defeats the purpose. The runtime has to fake the async behavior by dispatching the sync call to a thread pool thread, and the thread is occupied for the whole I/O operation. Match useAsync to your call pattern.

The throughline across this section is the same one from the async basics lesson. Async I/O on streams gives you back the thread during waits, which matters for concurrent server workloads and for UI responsiveness. It doesn't make the disk or network any faster, and for tiny operations the overhead can outweigh the benefit. The async lesson (lesson 18-01) covers the underlying model in more depth, including why blocking on .Result is dangerous in some contexts.

Putting the Pieces Together

A short, end-to-end snippet that uses most of the API surface from this lesson. It builds a small product list in memory, writes it to a file, copies that file to a backup, and reads the backup back to verify.

That snippet exercises construction, mode and access flags, async writes, CopyToAsync between two file streams, the read loop pattern, and disposal through using. The text formatting (header line, comma separators) is all done by hand here because text-aware stream wrappers are the topic of the _StreamReader & StreamWriter_ lesson. Working with bytes directly is fine for fixed-format payloads but quickly becomes awkward for arbitrary text, which is why StreamReader and StreamWriter exist.

What This Lesson Did Not Cover

Two related topics live in adjacent lessons and were left out on purpose so each lesson can own its scope.

Text-aware reading and writing on top of streams is the job of StreamReader and StreamWriter, which are covered in lesson 04 of this section. Those classes wrap any Stream (file, memory, or otherwise), buffer characters using a configurable encoding, and expose convenience methods like ReadLine and WriteLine that handle the byte-to-text conversion for you. Until then, working with text on a raw Stream means manually converting bytes through Encoding.UTF8.GetBytes and Encoding.UTF8.GetString, which is exactly what the examples in this lesson did.

Reading and writing typed primitives like int, double, and length-prefixed strings is the job of BinaryReader and BinaryWriter, covered in lesson 05. Those classes also wrap any Stream and add methods like ReadInt32, WriteDouble, and ReadString that take care of the byte-layout details for you.

Both of those classes follow the same pattern: take a Stream in the constructor, expose a higher-level API on top of it, and forward the disposal call to the underlying stream when they're disposed. Everything you learned about FileStream and MemoryStream in this lesson applies to the streams you'll wrap with those readers and writers.

Quiz

Streams Quiz

10 quizzes