AlgoMaster Logo

BinaryReader & BinaryWriter

Low Priority22 min readUpdated June 6, 2026

BinaryReader and BinaryWriter are the .NET classes for reading and writing raw bytes that represent primitive types and packed records, instead of human-readable text. They sit on top of any Stream and provide typed Read and Write methods for int, long, double, string, and others. This lesson covers when binary I/O fits over text, exactly how the bytes are laid out on disk, and how to design a small binary record format that round-trips cleanly through both classes.

Why Binary Instead of Text

Most file formats day to day are text: CSV, JSON, XML, log files. Text is friendly to humans, friendly to cat and grep, and friendly to ad-hoc tools. The cost is that every value gets converted to and from a string. Writing the integer 1000000 as text takes seven bytes, plus a delimiter. Writing it as a 32-bit binary integer takes exactly four bytes, no delimiter, no parsing. For one number the difference doesn't matter. For an order log with fifty million entries, the difference is measured in gigabytes and seconds.

Three things change when you move to a binary format.

The first is size. Numbers, dates, and bytes pack much tighter when they aren't represented as decimal digits surrounded by separators. A long timestamp is always 8 bytes binary. As a string like "638721542850000000" it's 18 bytes plus whatever delimiter you chose. The same data, less than half the storage.

The second is speed. Reading a binary integer means copying four bytes and reinterpreting them as an int. Reading a text integer means scanning characters, validating digits, and running int.Parse. The binary path skips the parser entirely and tends to be several times faster on large files. There's no formatting on the write side either, no string allocation, no culture lookup.

The third is fixed layout. A binary record where every field has a known size and order is trivial to seek into. If each entry is exactly 32 bytes, the 1000th entry sits at byte offset 32000. You can jump straight there with BaseStream.Position without scanning the whole file. Text formats almost never give you that, because the length of each row depends on the values.

The trade-off is that binary files are opaque. You can't open them in a text editor and read them. You can't grep them. Adding a new field later usually breaks every reader written for the old format, because the layout shifts. For data that humans need to touch, text wins. For high-volume logs, indexes, caches, or wire protocols where machines on both ends control the format, binary wins.

This table summarizes the trade-off.

ConcernText I/O (StreamReader / StreamWriter)Binary I/O (BinaryReader / BinaryWriter)
File sizeLarger; numbers expand to digits, separators add upSmaller; values use their native byte width
SpeedSlower; every value parsed or formattedFaster; values copied with little processing
Human-readableYes; opens in any editorNo; appears as garbled bytes
Tooling supportExcellent; works with grep, awk, diffPoor; needs custom viewers
Schema evolutionForgiving; add columns, ignore extrasBrittle; layout changes break old readers
Random accessHard; rows are variable lengthEasy when records are fixed width
Best forConfig files, exports, logs people readCaches, indexes, high-volume logs, wire formats

BinaryReader and BinaryWriter are the tools for the right column. They don't replace StreamReader and StreamWriter; they're for a different category of file.

Wrapping a Stream

BinaryReader and BinaryWriter don't open files themselves. They wrap an existing Stream, the same way StreamReader and StreamWriter do. Open a FileStream (or any other stream type), pass it to the constructor, and call typed Read or Write methods.

A minimal write example.

Three values, exactly 4 + 8 + 8 = 20 bytes, no delimiters. The matching read mirrors the writes in the same order.

The symmetry: Write(int) is paired with ReadInt32. Write(long) is paired with ReadInt64. Write(double) is paired with ReadDouble. The reader has no metadata describing what was written; it reads however many bytes the requested type needs and reinterprets them. Calling ReadInt64 after writing only an int reads four bytes of the value plus four bytes of whatever came next, producing a garbage number with no error from the runtime.

The using declarations matter. Disposing BinaryWriter flushes any buffered bytes to the underlying stream and then disposes the stream. Without using, if the program exits, the last few bytes might never reach disk, and the file ends up truncated.

The constructors have an overload that controls whether disposing the writer also disposes the stream.

The default for leaveOpen is false, which is what you want most of the time. Use true when you have several wrappers reading or writing the same stream in sequence and only the last one should close it.

Common Writer Methods

BinaryWriter has overloads of Write for every primitive type. The signature you call determines how many bytes hit the stream.

MethodBytes writtenRange or notes
Write(bool)10 for false, 1 for true
Write(byte)10 to 255
Write(sbyte)1-128 to 127
Write(short)216-bit signed
Write(ushort)216-bit unsigned
Write(int)432-bit signed
Write(uint)432-bit unsigned
Write(long)864-bit signed
Write(ulong)864-bit unsigned
Write(float)4IEEE 754 single precision
Write(double)8IEEE 754 double precision
Write(decimal)16.NET's 128-bit decimal layout
Write(char)1 to 4Encoded using the writer's encoding
Write(string)length-prefix + bytesSee below
Write(byte[])length of arrayWrites raw bytes, no length prefix
Write(byte[], int offset, int count)countWrites a slice of an array
Write(ReadOnlySpan<byte>)length of spanSpan overload, .NET 6+

A few of these deserve more attention.

Strings carry a length prefix. Write(string) first encodes the string into bytes using the configured encoding (UTF-8 by default in modern .NET), then writes a variable-length integer prefix that tells the reader how many bytes follow, then writes the encoded bytes. The prefix is called a 7-bit encoded integer. Each byte stores seven bits of the value; the high bit signals whether more bytes follow. For strings shorter than 128 bytes the prefix is one byte, for strings up to about 16 KB it's two bytes, and so on. This is why a string doesn't have a fixed width on disk and why ReadString knows when to stop reading without an explicit length.

The string is 22 ASCII bytes, the prefix takes one byte, total 23 on disk. The prefix value is 22, exactly the byte length of the encoded string. When the reader calls ReadString, it reads the prefix first to learn how many bytes to consume.

`Write(byte[])` does not write a length prefix. This is the one place the API is asymmetric. To read a byte array back, the length must be tracked separately, usually by writing it as an int first. Otherwise the reader won't know where the array ends.

This pattern, "write the count, then the elements", handles anything variable-length when the built-in string prefix doesn't apply.

The `ReadOnlySpan<byte>` overload (added in .NET 6) writes from a span without first allocating a `byte[]`. That matters for hot write paths slicing into an existing buffer. The on-disk format is identical to Write(byte[]), no prefix.

Write(byte[], int, int) and Write(ReadOnlySpan<byte>) both copy count bytes from the buffer into the stream's internal buffer. They're as fast as the underlying stream's write. Prefer the span overload when the data is already a slice of a larger array; it avoids allocating a temporary byte[].

`Write(decimal)` writes 16 bytes in .NET's internal layout (the four int parts that make up a decimal). The reader's ReadDecimal reverses it. The format is portable across .NET versions but not across other runtimes; for cross-platform decimal storage, write a string or use a fixed-point integer instead.

`Write(char)` uses the writer's encoding. With UTF-8 (the default on .NET 6+), an ASCII character writes as one byte and an emoji or non-Latin character can write as two, three, or four. The reader needs the same encoding to decode it back.

Common Reader Methods

BinaryReader mirrors the writer with one typed Read method per primitive.

MethodBytes consumedReturns
ReadBoolean()1bool
ReadByte()1byte
ReadSByte()1sbyte
ReadInt16() / ReadUInt16()2short / ushort
ReadInt32() / ReadUInt32()4int / uint
ReadInt64() / ReadUInt64()8long / ulong
ReadSingle()4float
ReadDouble()8double
ReadDecimal()16decimal
ReadChar()1 to 4char, decoded with the reader's encoding
ReadString()prefix + bytesstring, length read from the 7-bit prefix
ReadBytes(int count)up to countbyte[] of actual bytes read
Read(byte[] buffer, int index, int count)up to countnumber of bytes actually read
PeekChar()0 (peek only)next char as int, or -1 at end of stream

A few details to know.

`ReadBytes(count)` may return fewer bytes than requested. If the stream has fewer than count bytes left, the result is a shorter array, not an exception. Always check result.Length before assuming it matches count. The same is true of the lower-level Read(buffer, index, count), which returns the actual number of bytes copied.

`ReadString` throws `EndOfStreamException` if the stream is shorter than the prefix promised. For example, if the prefix says 200 bytes but only 50 remain, the reader throws. This is one of the few cases where mismatched data turns into a clear runtime error instead of silent corruption.

`PeekChar` looks at the next character without consuming it. It's useful with a marker byte that signals which type comes next. The method returns the character as an int, or -1 at end of stream.

The PeekChar loop terminates when the stream is exhausted, removing the need to track byte offsets manually. This pattern shows up in tag-based binary formats where every value is preceded by a small type code.

PeekChar decodes a character, then rewinds the stream's position. On a FileStream that's a cheap operation. On a network stream, the rewind requires the stream to support seeking, which not all streams do. Check BaseStream.CanSeek before relying on PeekChar against an arbitrary stream.

Endianness and Why It Usually Doesn't Bite You

Multi-byte values like int and long need an order to lay their bytes in. Two conventions exist. Little-endian writes the least significant byte first; the value 0x12345678 becomes 78 56 34 12 on disk. Big-endian writes the most significant byte first; the same value becomes 12 34 56 78. The choice doesn't change the meaning, only the layout.

BinaryWriter always writes little-endian, regardless of the host machine, and BinaryReader always reads little-endian. That's a deliberate cross-platform guarantee. A binary file written by a BinaryWriter on Windows reads the same on macOS, Linux, ARM, x64, all of them. Endianness doesn't enter the picture for values written through these classes.

The catch: to interoperate with a format that uses big-endian (most network protocols, many file formats from the Java or C++ world), BinaryWriter won't help. Two options:

The first is BitConverter. It exposes GetBytes and ToInt32 style helpers, but its endianness depends on the host CPU. On all common .NET platforms today (x86, x64, ARM64) it's little-endian, but the docs warn against relying on this. For host-byte-order conversions only.

The second, suited to explicit-endian work, is System.Buffers.Binary.BinaryPrimitives. It has explicit big-endian and little-endian overloads for every integer type.

For pure .NET-to-.NET binary files, stick with BinaryWriter and BinaryReader and ignore endianness entirely. For wire protocols, file formats from other ecosystems, or anywhere a spec demands a specific byte order, use BinaryPrimitives.

The constructor of BinaryReader and BinaryWriter takes an optional Encoding parameter for character data, and a leaveOpen parameter, but it does not take an endianness parameter. There is no built-in way to make BinaryWriter produce big-endian output. The little-endian default is permanent.

A Realistic Binary Record

The interesting use case for binary I/O is appending fixed-shape records to a file and reading them back, often by index. An order-log file is a good worked example. Each entry has the same fields in the same order: an order id, a timestamp, an order total, and a customer email.

A few details to point out in this code.

The reader and writer agree on the field order and types. There is no header, no schema, no tags. The reader has to know the file contains int, long, double, string records. If a maintainer adds a bool IsRefunded field to the writer without updating the reader, the reader misinterprets all the bytes after the first record, with no error. This is the brittleness mentioned earlier.

The read loop terminates with fs.Position < fs.Length. That's the standard idiom for "read until the stream is exhausted" with BinaryReader. Other options include catching EndOfStreamException (works but uses exceptions for control flow), or storing a record count at the start of the file and looping that many times.

Each record's size is variable because the email's length-prefixed string varies. If CustomerEmail were a fixed 64-byte field instead, every record would be exactly 4 + 8 + 8 + 64 = 84 bytes, and the byte offset of record N would be N * 84. Variable-length fields trade away that random-access property.

The flow of one record through the writer:

The four Write calls run in sequence and append to the file in the same order. The reader's four Read calls undo each step in the same order. Diverge anywhere on either side and the file's meaning changes.

Writing one entry to BinaryWriter is a handful of small writes into the underlying stream's buffer. The expensive part is flushing to disk, which FileStream handles in chunks of 4 KB by default. Calling writer.Flush() after every entry forces a buffer drain on every record and can slow throughput by an order of magnitude. Flush only when durability for a checkpoint is required.

Position, Seeking, and Random Access

BinaryReader and BinaryWriter both expose the underlying stream as BaseStream. That's how to manipulate position directly for random access. The Position property gives the current byte offset, and assignment moves the pointer.

A small example: appending one new entry to an existing fixed-size record file, and then reading the entry at index 2 directly without scanning the file from the start.

The seek is constant time and constant work, regardless of how big the file is. That's the random-access property of fixed-width binary records. It's how an index file or a memory-mapped cache locates a record by id without scanning.

Appending uses FileMode.Append, which sets the position to the end of the file before any write happens. Wrapping the same file with a BinaryWriter then writing the four fields adds them at the end without disturbing what's already there.

The diagram below shows the layout.

Each record is 20 bytes wide. Record N starts at byte offset N * 20. The seek is just a Position assignment, not a scan, so the cost doesn't grow with the number of records before it.

You can also seek with BaseStream.Seek(offset, SeekOrigin), which mirrors the lower-level Stream API. The three origins are Begin (offset from the start), Current (offset from the current position), and End (offset from the end, usually negative). For random access by index, Position = N * RECORD_SIZE is the readable form.

Seeking on a FileStream is cheap because the OS just updates the file pointer. Seeking on a NetworkStream is impossible, the stream throws NotSupportedException because there's nothing to seek into. Always check BaseStream.CanSeek before depending on random access against an arbitrary stream.

This pattern only works when records are fixed width. The moment a string field appears, the simple N * RECORD_SIZE formula breaks. A common workaround is to maintain a separate index file that maps each record id to its byte offset in the data file. Most embedded databases (SQLite, LevelDB, RocksDB) are elaborate versions of this idea.

Encoding, Strings, and the 7-Bit Length Prefix

Strings are the only BinaryWriter primitive whose on-disk size depends on its value. The format is "length prefix as a 7-bit encoded integer, followed by the string's bytes in the writer's encoding."

The encoding defaults to UTF-8 in modern .NET (since .NET Framework 4 and across all of .NET Core / .NET 5+). UTF-8 is a variable-width encoding: ASCII characters take one byte, common European accented characters take two, most CJK characters take three, and emoji and rare characters take four. The length prefix records the number of encoded bytes, not the number of characters.

Both strings have five characters but the Cyrillic one takes ten bytes encoded, because each Cyrillic character is two bytes in UTF-8. The length prefix on disk holds the byte count, so when the reader calls ReadString it consumes exactly the right number of bytes.

Pass a different encoding through the constructor to force a specific layout.

The matching reader needs the same encoding. If a writer used UTF-8 and the reader uses Latin-1 (Encoding.GetEncoding("ISO-8859-1")), non-ASCII characters decode to the wrong glyphs without throwing. This is the classic "mojibake" failure mode for binary string data.

The 7-bit encoded prefix itself. The format reads bytes one at a time, taking the lower seven bits of each, until it sees a byte whose top bit is zero (the last byte). Two examples:

  • A 22-byte string writes its prefix as a single byte: 0x16 (22 in binary is 00010110, no high bit set, one byte).
  • A 200-byte string writes its prefix as two bytes: 0xC8 0x01. The first byte has its top bit set, signalling "more to come". The lower seven bits of the first byte plus the seven bits of the second give 0000001 1001000 = 200.

The prefix rarely needs direct handling. Write(string) and ReadString handle it. The format is documented because hand-written readers in other languages need to know how to parse it.

The flow shows what happens when Write("alice@example-shop.com") runs against a UTF-8 writer. The string is encoded into 22 bytes, the writer prepends a one-byte length prefix, and 23 bytes hit the stream.

Pitfalls and How to Avoid Them

Binary I/O is unforgiving. The runtime can't validate that what you read matches what was written, and most mismatches turn into silently wrong data instead of exceptions. A short list of the failures that come up most.

Reading the wrong type. Calling ReadInt64 after a Write(int) returns a number built from the int plus four bytes of whatever follows. The fix is to keep the read sequence rigorously parallel to the write sequence, and document the field order somewhere a future reader will find it.

Mismatched encoding for strings. The default in .NET 6+ is UTF-8, but older code or external producers might use ASCII, UTF-16, or worse. If reading produces correct length prefixes but wrong characters, encoding is the suspect. Always pass an explicit Encoding to both constructors when interop matters.

Forgetting to flush. Writing a small file and exiting abnormally before disposal runs can lose buffered bytes. The fix is the using statement, which guarantees disposal even on exception. Avoid explicit Flush() calls "to be safe"; let the wrappers do it on dispose.

Not closing in the right order. Wrapping BinaryWriter around FileStream and disposing only the FileStream first loses the writer's buffer and can truncate the file. The using declarations in the examples handle this by disposing the writer first (innermost scope ends first), which flushes its buffer into the stream before the stream itself closes.

Asymmetric `Write(byte[])` and `ReadBytes`. Write(byte[]) writes raw bytes with no length prefix, but ReadBytes requires a count. Without storing the length separately before the array, there's no way to pick the count for ReadBytes. The standard idiom is writer.Write(arr.Length); writer.Write(arr); followed by int n = reader.ReadInt32(); byte[] arr = reader.ReadBytes(n);.

Endianness assumptions when interoperating. BinaryWriter is always little-endian, by design. When reading or producing big-endian data for a wire protocol or a Java-written file, switch to BinaryPrimitives.ReadInt32BigEndian and related methods instead of forcing BinaryReader into a role it can't fulfill.

Treating binary files as version-stable. A new field tacked onto the end of a record may seem safe, but every old reader runs off the end into the next record's bytes. If versioning matters, write a version number as the first int of the file (or of each record) and switch on it in the reader. Or use a real serialization format (length-prefixed records, JSON, or Protocol Buffers) instead of hand-rolled binary.

Reading past end of stream. Typed Read methods throw EndOfStreamException when there aren't enough bytes left. The defensive pattern is to bound the loop with fs.Position < fs.Length, or to catch the exception explicitly when reading an unknown number of records.

The position-based loop is cleaner for fixed-shape data, and the try/catch is fine for streams that don't support Length or Position. Pick one and stick with it; mixing the two in the same reader makes the code hard to follow.

When to Use This vs. Something Else

BinaryReader and BinaryWriter are low-level. They give full control over bytes on disk and provide no help with schema, versioning, validation, or cross-language interop. That fits a narrow band of problems.

Use them for:

  • High-volume internal logs or caches with both sides under control and a stable format.
  • Index files where fixed-width records support O(1) seeks.
  • Game save files, profile snapshots, or other private formats where size and load speed matter more than flexibility.
  • Hand-rolled wire protocols that can't be changed.

Use something else when:

  • Humans need to read or edit the file. Use JSON, YAML, or CSV.
  • The schema will evolve over time. Use a versioned format with a real schema, like Protocol Buffers, MessagePack, or Avro. The structured serialization options (System.Text.Json, System.Xml.Serialization) come up in lessons 7 and 8 of this section.
  • Multiple languages or runtimes need to consume the file. .NET-specific layouts like Write(decimal)'s 16-byte form don't translate to other ecosystems.
  • Named fields, optional fields, or arbitrary nesting are needed. Hand-rolled binary handles flat records well and structures poorly.

The choice usually comes down to who reads the file, how often the format changes, and whether size or speed is on the critical path. When the answer is "internal, stable, and yes," BinaryReader and BinaryWriter fit well.

Quiz

BinaryReader & BinaryWriter Quiz

10 quizzes