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.
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.
| Concern | Text I/O (StreamReader / StreamWriter) | Binary I/O (BinaryReader / BinaryWriter) |
|---|---|---|
| File size | Larger; numbers expand to digits, separators add up | Smaller; values use their native byte width |
| Speed | Slower; every value parsed or formatted | Faster; values copied with little processing |
| Human-readable | Yes; opens in any editor | No; appears as garbled bytes |
| Tooling support | Excellent; works with grep, awk, diff | Poor; needs custom viewers |
| Schema evolution | Forgiving; add columns, ignore extras | Brittle; layout changes break old readers |
| Random access | Hard; rows are variable length | Easy when records are fixed width |
| Best for | Config files, exports, logs people read | Caches, 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.
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.
BinaryWriter has overloads of Write for every primitive type. The signature you call determines how many bytes hit the stream.
| Method | Bytes written | Range or notes |
|---|---|---|
Write(bool) | 1 | 0 for false, 1 for true |
Write(byte) | 1 | 0 to 255 |
Write(sbyte) | 1 | -128 to 127 |
Write(short) | 2 | 16-bit signed |
Write(ushort) | 2 | 16-bit unsigned |
Write(int) | 4 | 32-bit signed |
Write(uint) | 4 | 32-bit unsigned |
Write(long) | 8 | 64-bit signed |
Write(ulong) | 8 | 64-bit unsigned |
Write(float) | 4 | IEEE 754 single precision |
Write(double) | 8 | IEEE 754 double precision |
Write(decimal) | 16 | .NET's 128-bit decimal layout |
Write(char) | 1 to 4 | Encoded using the writer's encoding |
Write(string) | length-prefix + bytes | See below |
Write(byte[]) | length of array | Writes raw bytes, no length prefix |
Write(byte[], int offset, int count) | count | Writes a slice of an array |
Write(ReadOnlySpan<byte>) | length of span | Span 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.
BinaryReader mirrors the writer with one typed Read method per primitive.
| Method | Bytes consumed | Returns |
|---|---|---|
ReadBoolean() | 1 | bool |
ReadByte() | 1 | byte |
ReadSByte() | 1 | sbyte |
ReadInt16() / ReadUInt16() | 2 | short / ushort |
ReadInt32() / ReadUInt32() | 4 | int / uint |
ReadInt64() / ReadUInt64() | 8 | long / ulong |
ReadSingle() | 4 | float |
ReadDouble() | 8 | double |
ReadDecimal() | 16 | decimal |
ReadChar() | 1 to 4 | char, decoded with the reader's encoding |
ReadString() | prefix + bytes | string, length read from the 7-bit prefix |
ReadBytes(int count) | up to count | byte[] of actual bytes read |
Read(byte[] buffer, int index, int count) | up to count | number 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.
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.
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.
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.
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:
0x16 (22 in binary is 00010110, no high bit set, one byte).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.
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.
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:
Use something else when:
System.Text.Json, System.Xml.Serialization) come up in lessons 7 and 8 of this section.Write(decimal)'s 16-byte form don't translate to other ecosystems.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.
10 quizzes