Big-endian versus little-endian byte order, which platforms and protocols use which, network byte order, and the .NET APIs (BinaryPrimitives, BinaryReader, BitConverter, Guid) that handle byte order explicitly or implicitly.
Endianness is the order in which the bytes of a multi-byte value are stored. Little-endian puts the least significant byte first, so 0x12345678 is stored as 78 56 34 12. Big-endian puts the most significant byte first: 12 34 56 78. x64 and Arm64 machines running .NET are little-endian, but network protocols ("network byte order") and many file formats are big-endian. In .NET I use BinaryPrimitives.ReadInt32BigEndian and friends so the byte order is explicit and portable. BitConverter follows the machine, and BinaryReader/BinaryWriter are always little-endian.
A 32-bit integer occupies four bytes. The CPU has to choose which byte goes at the lowest address.
Value 0x12345678 |
addr+0 | addr+1 | addr+2 | addr+3 |
|---|---|---|---|---|
| Little-endian | 78 |
56 |
34 |
12 |
| Big-endian | 12 |
34 |
56 |
78 |
It only matters when you reinterpret bytes: writing a number to a file or socket, reading a binary format, hashing, or casting a byte span to an integer. Inside your program, x << 8 and arithmetic behave the same on every platform.
| Little-endian | Big-endian |
|---|---|
| x86/x64, Arm64 as used by Windows, Linux, macOS, iOS and Android | TCP/IP headers ("network byte order") |
UTF-16LE (Encoding.Unicode), most Windows file formats (BMP, PE) |
PNG, JPEG markers, Java's DataOutputStream, Kafka and many binary protocols, X.509/ASN.1 DER |
BinaryReader / BinaryWriter |
Many financial and telecom wire formats |
BinaryPrimitives (System.Buffers.Binary): ReadInt32BigEndian, WriteUInt16LittleEndian, ReverseEndianness, and so on. Explicit, span-based, no allocations, and the JIT turns them into a single load plus a byte-swap instruction. This is the default choice.BitConverter: GetBytes and ToInt32 use the machine's order; BitConverter.IsLittleEndian tells you which. Code that uses it for a wire format is only correct by accident.BinaryReader / BinaryWriter: always little-endian, whatever the machine. Fine for your own formats, wrong for big-endian protocols.IPAddress.HostToNetworkOrder / NetworkToHostOrder: older helpers for socket code.Guid: ToByteArray() writes the first three groups little-endian and the rest as-is, so the bytes don't match the string form. .NET 8 added ToByteArray(bigEndian: true) and new Guid(bytes, bigEndian: true) for RFC 4122 byte order.A .NET service read message lengths from a Java service's TCP stream with BinaryReader.ReadInt32(). Java writes big-endian, so a 300-byte message (00 00 01 2C) was read as 738,263,040 bytes; the service tried to allocate 700 MB and crashed under load. Switching to BinaryPrimitives.ReadInt32BigEndian and adding a maximum-length check fixed both the bug and the denial-of-service risk.
Checks low-level data literacy needed for protocols, file formats and interop.
Green flags: clear definition with an example, knows which platforms and protocols use which order, picks BinaryPrimitives, knows BitConverter and BinaryReader behaviour, mentions validating lengths.
Red flags: thinks endianness affects all data, can't name an API, would use BitConverter for a network protocol.
int value = 0x0A0B0C0D;
lower addresses ------------------> higher addresses
LE: [0D] [0C] [0B] [0A] (least significant byte first)
BE: [0A] [0B] [0C] [0D] (most significant byte first, reads like the hex)
The names come from Gulliver's Travels (which end of an egg to crack). Bit order inside a byte isn't affected; you never see it from C#.
| Operation | Endianness-sensitive? |
|---|---|
Arithmetic, shifts, comparisons on int |
No |
BitConverter.GetBytes, MemoryMarshal.Cast<byte, int> |
Yes, machine order |
BinaryWriter.Write(int) |
Yes, always little-endian |
| Writing a length prefix on a socket | Yes, the protocol decides |
| Hashing an integer's bytes | Yes, hash differs by byte order |
Encoding.Unicode vs Encoding.BigEndianUnicode |
Yes, UTF-16LE vs UTF-16BE |
| UTF-8 | No, it's a byte sequence |
BinaryPrimitives.ReadInt32BigEndian(span) compiles to a 4-byte load and a bswap (x64) or rev (Arm64) instruction when the machine is little-endian, and a plain load when it already matches. There's no loop and no allocation, which is why it's used throughout Kestrel, System.Text.Json and the gRPC libraries.
WriteInt32BigEndian), never rely on defaults.string form: 00112233-4455-6677-8899-AABBCCDDEEFF
ToByteArray(): 33 22 11 00 55 44 77 66 88 99 AA BB CC DD EE FF
ToByteArray(true): 00 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF (.NET 8+)
Systems that store GUIDs as 16 raw bytes (PostgreSQL uuid, Java, Python) use the big-endian layout. Mixing the layouts gives "the same" GUID different bytes, and lookups fail.
Rarely for .NET: IBM s390x (mainframes) is big-endian and has community .NET builds. The real-world reason to care is data formats and protocols, not CPUs.
0x12345678 with BitConverter.GetBytes, BinaryWriter and BinaryPrimitives.WriteInt32BigEndian, and print each result in hex.uint values at byte offsets 16 and 20.Guid to bytes with and without bigEndian: true and compare them with the string form.PipeReader or a Stream that rejects lengths over 1 MB.namespace Endianness;
using System.Buffers.Binary;
public static class Program
{
public static void Main()
{
Console.WriteLine($"This machine is little-endian: {BitConverter.IsLittleEndian}");
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteInt32BigEndian(buffer, 0x12345678);
Console.WriteLine(Convert.ToHexString(buffer)); // 12345678
BinaryPrimitives.WriteInt32LittleEndian(buffer, 0x12345678);
Console.WriteLine(Convert.ToHexString(buffer)); // 78563412
// Reading the dimensions from a PNG header (big-endian per the PNG specification)
byte[] png = File.ReadAllBytes("logo.png");
uint width = BinaryPrimitives.ReadUInt32BigEndian(png.AsSpan(16, 4));
uint height = BinaryPrimitives.ReadUInt32BigEndian(png.AsSpan(20, 4));
Console.WriteLine($"{width} x {height}");
var id = Guid.Parse("00112233-4455-6677-8899-aabbccddeeff");
Console.WriteLine(Convert.ToHexString(id.ToByteArray())); // 33221100554477668899AABBCCDDEEFF
Console.WriteLine(Convert.ToHexString(id.ToByteArray(bigEndian: true))); // 00112233445566778899AABBCCDDEEFF
}
}
public static class FrameReader
{
private const int MaxFrameLength = 1024 * 1024;
/// Reads one big-endian length-prefixed frame from a stream.
public static async Task<byte[]> ReadFrameAsync(Stream stream, CancellationToken ct)
{
byte[] header = new byte[4];
await stream.ReadExactlyAsync(header, ct);
int length = BinaryPrimitives.ReadInt32BigEndian(header);
if (length is < 0 or > MaxFrameLength)
{
throw new InvalidDataException($"Frame length {length} is out of range.");
}
byte[] payload = new byte[length];
await stream.ReadExactlyAsync(payload, ct);
return payload;
}
}
BitConverter for a wire or file format. Why it is wrong: It uses the machine's byte order, so the format is defined by whatever CPU wrote it and is wrong for big-endian protocols. BitConverter.GetBytes also allocates a new array for every value, which adds GC pressure when serialising millions of fields. Instead: Use BinaryPrimitives with the byte order named in the specification. BitConverter is acceptable only for data that never leaves the process or machine (for example an in-memory cache key), and even then BitConverter.TryWriteBytes(span, value) avoids the allocation.BinaryReader matches any protocol. Why it is wrong: It is always little-endian, so reading a Java or network big-endian length gives a huge or negative number. Instead: Read the bytes and decode with ReadInt32BigEndian (or the matching method).OutOfMemoryException or a denial of service. Instead: Validate lengths against a maximum before allocating, and read with ReadExactlyAsync.ToByteArray() bytes with non-.NET systems. Why it is wrong: .NET's mixed layout does not match the RFC byte order other platforms use, so IDs do not match. Instead: Exchange GUIDs as strings, or use ToByteArray(bigEndian: true).BinaryPrimitives.ReverseEndianness or the explicit read and write methods.Seniors treat byte order as part of the contract of a binary format, not a property of the machine. They make it explicit with BinaryPrimitives and know the failure modes of getting it wrong:
| Risk | Example | Defence |
|---|---|---|
| Silent corruption | ID 1 read as 16,777,216 maps to the wrong record | golden-byte tests captured from the other system |
| Denial of service | length prefix misread as 700 MB, allocation crashes the pod | maximum frame length, ReadExactlyAsync, reject and close |
| Interop mismatch | GUID bytes differ between .NET and PostgreSQL/Java | exchange as strings or RFC byte order |
Performance: in high-throughput parsers (Kestrel, gRPC, Kafka clients) BinaryPrimitives over Span<byte> or ReadOnlySequence<byte> with PipeReader is the standard pattern: no allocations, one instruction per field. BitConverter.GetBytes plus array copies can cost more than the business logic at hundreds of thousands of messages per second.
Custom versus standard formats: a hand-rolled format is justified for a partner's fixed layout or a measured hot path; otherwise Protobuf or MessagePack give portability, versioning and tooling for free.
Several real bugs. Encoding.ASCII turns José into Jos?, so names are corrupted. The field limit is 32 bytes, but the code truncates at 32 chars; once you fix the encoding to UTF-8, a 32-char name can be up to 96 bytes and overflows the buffer. Substring can also split a surrogate pair. BitConverter writes the machine's little-endian order, but the partner specification says big-endian, so every ID and length is wrong. I'd rewrite it with BinaryPrimitives, UTF-8 truncated on grapheme boundaries to 32 bytes, input validation, and golden-byte tests from the partner's specification.
A bit is a single 0 or 1; a byte is 8 bits and is the smallest unit memory addresses. C# numeric types have fixed sizes on every platform: byte 1 byte, short 2, int 4, long 8, Int128 16, Half 2, float 4, double 8, decimal 16. Signed integers use two's complement, so int runs from −2,147,483,648 to 2,147,483,647. Integer arithmetic is unchecked by default: int.MaxValue + 1 silently wraps to int.MinValue, while float/double never throw and produce Infinity or NaN, and decimal always throws. Make overflow explicit where a wrap would be a bug (money, counters, sizes from external input): use checked, a wider type, or generic-math conversions such as int.CreateChecked(x) and int.CreateSaturating(x).
How to tell it (STAR)
**Situation:** Our [pricing worker] at [company] kept crashing with out-of-memory errors when consuming a TCP feed from a Java service. **Task:** I had to find the cause and make the consumer robust. **Action:** In a packet capture the length prefix was 300 bytes, but our reader computed about 738 million. `BinaryReader` reads little-endian, while Java writes big-endian. I switched to `BinaryPrimitives.ReadInt32BigEndian`, added a maximum frame length, and wrote tests using real captured frames. **Result:** The crashes stopped, the worker handled [2,000] messages a second steadily, and the frame limit closed a denial-of-service hole we hadn't noticed.
Last updated 23 Oct 2026.