A binary file is a file that stores data as bytes following a specific structure.
So far we have talked about text files. They are great: you can open them with Notepad, read them and understand them. But they have a major drawback in engineering: they can be inefficient.
Imagine you need to save one million sensor readings, represented as decimal numbers (double).
- In Text: The number
12345.6789takes 10 bytes (one byte per character). Plus, it needs to be parsed (text to number), which consumes CPU. - In Binary: A
doublealways takes 8 bytes. There is no text parsing because a fixed binary representation is stored.
Binary files are not meant to be read by humans, but by machines. They are compact, fast and structured.
The foundation: FileStream
It all starts with FileStream. Unlike the quick text methods, here we need to control the byte stream precisely.
string path = "data.bin";
// FileMode.Create: Creates or overwrites
// FileAccess.Write: Write only
using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write))
{
// Here we have an open stream ready to receive bytes
// fs.WriteByte(0xFF); ...
}However, using FileStream directly is painful because it only understands byte[]. If you want to write an integer (int), you would have to manually break it into 4 bytes. To avoid that suffering, we use Wrappers.
BinaryWriter and BinaryReader
These two classes are our translators. They take your C# data types (int, float, bool, string) and convert them to their raw binary representation to write to the stream.
Order here is sacred. In a binary file there are no “lines” or labels. If you write an integer and then a boolean, you must know that you have to read them in exactly that order.
using (FileStream fs = new FileStream("sensor.dat", FileMode.Create))
using (BinaryWriter writer = new BinaryWriter(fs))
{
int sensorId = 1;
double temperature = 23.5;
bool active = true;
// Write sequentially
writer.Write(sensorId); // 4 bytes
writer.Write(temperature); // 8 bytes
writer.Write(active); // 1 byte
}To retrieve the information, we do the reverse process.
using (FileStream fs = new FileStream("sensor.dat", FileMode.Open))
using (BinaryReader reader = new BinaryReader(fs))
{
// ORDER IS CRITICAL!
int id = reader.ReadInt32();
double temp = reader.ReadDouble();
bool status = reader.ReadBoolean();
Console.WriteLine($"Sensor {id}: {temp}ºC (Active: {status})");
}If you get the order wrong (e.g., trying to read a double where there was an int), you will read 8 bytes of garbage, misalign the entire file, and the resulting data will be meaningless.
Structures and text strings
Strings in binary are a special case. Since text can be of any length, BinaryWriter does something smart:
- Calculates the length of the text.
- Writes the length at the beginning (as a 7-bit encoded integer).
- Writes the bytes of the text.
This means reader.ReadString() knows exactly how many bytes to read.
Watch out for C++ interoperability: If this file will be read by a C or C++ program, they won’t expect that C# length prefix. You will have to write fixed-length strings or handle it manually.
Random access (Seek)
One of the great advantages of binary files is that they usually have records of fixed size.
If we store 1000 measurements and each takes 12 bytes (4 of int + 8 of double), we know mathematically where measurement number 500 is. No need to read the previous 499.
We can jump directly using BaseStream.Seek.
long position = 500 * 12; // (Index * RecordSize)
using (FileStream fs = new FileStream("measurements.bin", FileMode.Open))
using (BinaryReader reader = new BinaryReader(fs))
{
// Jump directly to the position
reader.BaseStream.Seek(position, SeekOrigin.Begin);
// Read only that record
int id = reader.ReadInt32();
double value = reader.ReadDouble();
}The endianness problem
If you work with hardware, microcontrollers (Arduino, STM32) or network protocols, you will encounter endianness.
Endianness defines the order of bytes in a multibyte data type.
- Little Endian (Intel/AMD, C# by default): The least significant byte comes first. The number
0x12345678is stored as78 56 34 12. - Big Endian (Networks, some microcontrollers): The most significant byte comes first. It is stored as
12 34 56 78.
If you read a file generated in big-endian with BinaryReader, the numbers will be interpreted incorrectly because this reader uses little-endian for numeric types.
To solve this, we sometimes have to read raw bytes and use utilities like BinaryPrimitives.
using System.Buffers.Binary;
// Big Endian reading for a 16-bit integer (short)
byte[] bytes = reader.ReadBytes(2);
short value = BinaryPrimitives.ReadInt16BigEndian(bytes);Serialization of structures using marshaling
When we have complex data structures in engineering (e.g., a BMP file header or a TCP packet), reading field by field with writer.Write is tedious.
We can dump an entire struct to bytes directly. This is an advanced technique and requires using System.Runtime.InteropServices.
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Header
{
public int Id;
public float Version;
public short Flags;
}
// Convert struct to bytes
int size = Marshal.SizeOf(myHeader);
byte[] arr = new byte[size];
IntPtr ptr = Marshal.AllocHGlobal(size);
try
{
Marshal.StructureToPtr(myHeader, ptr, false);
Marshal.Copy(ptr, arr, 0, size);
}
finally
{
Marshal.FreeHGlobal(ptr);
}
// Now 'arr' contains the structure in pure binary, ready to be saved.In modern .NET versions, we prefer using Span<T> and MemoryMarshal to do this much faster and safer without using explicit pointers.