Skip to content
Back to Blog
Tutorials

Endianness: Why the Same Bytes Read as Two Different Numbers

Four bytes, 12 34 56 78, read as 0x12345678 or 0x78563412 depending on the reader. Endianness in JavaScript, Python, Go, PNG and GZIP, all measured online.

13 min read

Endianness: Why the Same Bytes Read as Two Different Numbers

Four bytes sit in memory: 12 34 56 78. Read them with three different JavaScript APIs and two different numbers come back.

ReadResult
new DataView(buf).getUint32(0)0x12345678
new DataView(buf).getUint32(0, true)0x78563412
new Uint32Array(buf)[0]0x78563412

That is endianness. Nothing is broken and nothing throws. Each call applies a different rule about which end of a multi-byte number comes first.

Endianness is not really a question about your machine. The question is whose convention the bytes were written under. A PNG on your disk is big-endian. A GZIP file next to it is little-endian. Your CPU gets no vote in either case.

Everything below was measured on Node v26.7.0 and Python 3.14.6 under macOS darwin arm64, where os.endianness() returns LE and sys.byteorder returns little.

1. What big-endian and little-endian actually mean

Take the 32-bit value 0x12345678. It is four bytes: 12 is the most significant, 78 the least. Endianness decides which of them lands at the lowest address.

LayoutAddress 0Address 1Address 2Address 3
Big-endian12345678
Little-endian78563412

Big-endian stores the big end first, the same order you would write the number on paper. Little-endian stores the little end first. Neither one touches the bits inside a byte: 0x12 is 0x12 in both layouts. Only whole bytes move.

If the hex-to-binary step is the shaky part, the base converter lays each byte out in binary next to its hex form, and the binary, hex and octal conversion guide covers the notation itself.

1.1 Why there are two of them

The split is historical rather than principled. Big-endian reads the way people write numbers, and it became the convention for network protocols early enough that it stuck. Little-endian won on the CPU side because x86 uses it and ARM defaults to it. The efficiency argument repeated in nearly every article on the subject gets measured in section 8, where it comes out at about 2%.

2. Endianness is a property of the format, not the platform

Byte order is fixed by whoever wrote the bytes, not by the machine reading them. Short explanations tend to skip past this.

The proof takes one laptop and two files. On the same arm64 machine, inside the same process, these two need opposite decoding.

2.1 PNG is big-endian

RFC 2083 requires multi-byte integers in network byte order, so every length, width and height in a PNG is big-endian. The header layout is fixed: eight signature bytes, then a four-byte chunk length, then the four-character chunk type, then width and height.

const fs = require('node:fs');
const png = fs.readFileSync('public/og/base-converter.png');

png.subarray(0, 8).toString('hex'); // '89504e470d0a1a0a' — PNG signature
png.readUInt32BE(8);                // 13   — IHDR chunk length
png.readUInt32BE(16);               // 1200 — image width
png.readUInt32BE(20);               // 630  — image height

png.readUInt32LE(16);               // the same four bytes, read the wrong way
FieldBytesBig-endianLittle-endian
IHDR length00 00 00 0d13218,103,808
Image width00 00 04 b012002,953,052,160

2.2 GZIP is little-endian

RFC 1952 §2.3.1 spells it out: least significant byte first. The last four bytes of a gzip stream are ISIZE, the uncompressed size. Compress 300 bytes of A and check:

python3 -c "import gzip,sys; sys.stdout.buffer.write(gzip.compress(b'A'*300))" > a.gz
const gz = fs.readFileSync('a.gz');

gz.subarray(-4).toString('hex');   // '2c010000'
gz.readUInt32LE(gz.length - 4);    // 300 — correct
gz.readUInt32BE(gz.length - 4);    // the same four bytes, read the wrong way
FieldBytesLittle-endianBig-endian
Trailer ISIZE2c 01 00 00300738,263,040

Same machine, same process, the same four-byte read primitive. If your working rule is “my machine is little-endian so I read little-endian”, one of these two files decodes into garbage.

3. JavaScript: two APIs, two opposite defaults

The two ways to view an ArrayBuffer disagree with each other by default, and most write-ups on byte order never mention it.

3.1 DataView endianness: the third argument decides

DataView methods take an optional littleEndian flag as their last argument. Leave it out and you get big-endian. setUint32(0, x) and setUint32(0, x, false) are the same call.

const buf = new ArrayBuffer(4);
new Uint8Array(buf).set([0x12, 0x34, 0x56, 0x78]);
const dv = new DataView(buf);

dv.getUint32(0).toString(16);       // '12345678'  — big-endian, the default
dv.getUint32(0, true).toString(16); // '78563412'  — littleEndian: true

Writing behaves the same way in reverse:

const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join(' ');

dv.setUint32(0, 0x12345678);
hex(buf); // '12 34 56 78'

dv.setUint32(0, 0x12345678, true);
hex(buf); // '78 56 34 12'

3.2 TypedArray follows the platform, and you cannot change it

Uint32Array, Int16Array, Float64Array and the rest use whatever the CPU uses. There is no argument and no constructor flag to override it. On this arm64 machine that means little-endian, the exact opposite of the DataView default.

new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'

So a single ArrayBuffer read through new DataView(buf).getUint32(0) and through new Uint32Array(buf)[0] yields 0x12345678 and 0x78563412. Both are correct; they answer different questions.

One-byte views are immune, because byte order only exists for units wider than a byte. Uint8Array and Int8Array never need a flag. Widen by one step and it returns:

const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'

3.3 Node Buffer: name the order in the method

Buffer skips defaults and puts the order in the method name, so Node code is usually the easiest of the three to audit.

const b = Buffer.from([0x12, 0x34, 0x56, 0x78]);

b.readUInt32BE(0).toString(16); // '12345678'
b.readUInt32LE(0).toString(16); // '78563412'

b.swap32().toString('hex');     // '78563412' — mutates b in place

swap32() reverses each group of four bytes and returns the same buffer rather than a copy. That is convenient when you have a whole array of wrong-endian integers, and dangerous if you forgot the buffer was shared.

4. Python struct: five prefixes, and what @ really costs

4.1 < > ! = @: the five struct pack byte order prefixes

Packing 0x12345678 as an unsigned 32-bit integer, one line per prefix:

import struct

struct.pack('<I', 0x12345678).hex(' ')  # '78 56 34 12'  little-endian
struct.pack('>I', 0x12345678).hex(' ')  # '12 34 56 78'  big-endian
struct.pack('!I', 0x12345678).hex(' ')  # '12 34 56 78'  network order
struct.pack('=I', 0x12345678).hex(' ')  # '78 56 34 12'  native order, standard sizes
struct.pack('@I', 0x12345678).hex(' ')  # '78 56 34 12'  native order, native alignment

! and > produce identical bytes because network byte order is big-endian. Unpacking mirrors it, and int.from_bytes gives the same pair:

hex(struct.unpack('>I', b'\x12\x34\x56\x78')[0])    # '0x12345678'
hex(struct.unpack('<I', b'\x12\x34\x56\x78')[0])    # '0x78563412'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'big'))     # '0x12345678'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'little'))  # '0x78563412'

4.2 @ and = differ in padding, not byte order

Both follow the platform, so on this machine both write little-endian. The difference is alignment, and it changes the size of your struct:

struct.calcsize('@ci')  # 8
struct.calcsize('=ci')  # 5
struct.calcsize('<ci')  # 5

A char followed by an int is five bytes of data. Under @, the default when you write no prefix at all, Python inserts three padding bytes so the int starts on a four-byte boundary. Under = or any explicit byte-order prefix, the padding disappears.

That is the mechanism behind a bug that reads as impossible: someone adds < to fix a byte-order problem and the record length changes underneath them. Nothing about the byte order caused it. Switching away from @ silently switched off native alignment too.

5. Network byte order, and how other languages spell it

Network byte order is big-endian. TCP, UDP and IP headers all carry their multi-byte fields that way, a decision that dates back to when big-endian hardware was common enough that somebody had to pick a side. C exposes the conversion through htons, htonl, ntohs and ntohl: host to network and back, for shorts and longs. On a little-endian host they swap; on a big-endian host they are no-ops, so code that omits them works fine until it meets a different machine.

Go takes the opposite approach and refuses to have a default at all:

import "encoding/binary"

v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)

binary.BigEndian and binary.LittleEndian are values you name at the call site. There is no platform-dependent path and no optional flag to forget, so reviewing byte order in Go comes down to reading the identifier.

The same discipline applies to fixed-point data. A Q15 or Q31 sample is a plain 16- or 32-bit integer once it leaves your code, so it inherits the byte-order question along with everything else; the Q format converter shows the integer behind the fraction, and that integer is what gets ordered.

6. Floating-point numbers have a byte order too

A float is not special. IEEE 754 defines the bit pattern, and then the same four or eight bytes get laid down in whatever order the format demands.

struct.pack('>f', 1.0).hex(' ')  # '3f 80 00 00'
struct.pack('<f', 1.0).hex(' ')  # '00 00 80 3f'
struct.pack('>d', 0.1).hex(' ')  # '3f b9 99 99 99 99 99 9a'
struct.pack('<d', 0.1).hex(' ')  # '9a 99 99 99 99 99 b9 3f'

The IEEE 754 converter answers the first half of the question: type 3.14159 with FP32 selected and you get 0x40490FD0. The second half is the order those four bytes reach the file in, which is what this article is about.

The double 0.1 row is also the reason 0.1 + 0.2 misbehaves. Those repeating 99 bytes are a binary expansion that never terminates, which the floating-point precision guide takes apart.

6.1 float 1.0 is 3f 80 00 00, or 00 00 80 3f

1.0 in FP32 is a useful canary because its byte pattern is so lopsided. Big-endian writes 3f 80 00 00; little-endian writes 00 00 80 3f. Dump an unfamiliar binary format, find a field you know should be 1.0, and the two trailing zeros tell you which end you are at. It works for double too, where the same value has six zero bytes clustered on one side.

7. Byte order in text encodings: the BOM is just a declaration

UTF-16 and UTF-32 are made of multi-byte units, so they run into the problem this article describes. Their answer is to let the file announce its own order with a byte order mark:

Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF is the BOM
Buffer.from('A', 'utf16le').toString('hex');      // '4100'

fffe at the front means little-endian; feff means big-endian. The BOM is the most common case of a format declaring its own byte order instead of assuming one. Why UTF-8 needs no BOM, and what the mark costs you when it turns up uninvited, is covered in the UTF-8, UTF-16 and Unicode encoding guide.

8. Is little-endian faster? What 40 million iterations say

The claim that little-endian is more efficient shows up in search-engine summaries and in most Chinese-language articles on the topic. It is testable. Forty million iterations of a single 32-bit read on this arm64 machine:

Pathns/op
DataView.getUint32(4, true) (little-endian)4.4267
DataView.getUint32(4) (big-endian)4.5200
Buffer.readUInt32LE(4)1.5173
Buffer.readUInt32BE(4)5.0893
RatioFactor
DataView big-endian / little-endian1.021×
Buffer big-endian / little-endian3.354×

Those two ratios measure different things, and reporting only the second one would be the same mistake the articles make.

The DataView pair is the honest measurement of byte-order cost. Both calls compile to the same inline path in V8; the big-endian one carries one extra ARM REV instruction to swap the bytes. That is the 1.021×, roughly 2%. It is small, but it is not zero, and rounding it to “free” is wrong.

The Buffer pair measures something else entirely. V8 has a dedicated fast path for readUInt32LE that readUInt32BE does not get, so the 3.354× is an implementation difference in one runtime, not the price of swapping bytes on a CPU. Quoting it as evidence that big-endian is slow would be wrong. Change the runtime and the number changes with it.

On hardware like this, byte-order conversion is too cheap to belong in a format design discussion. The historical efficiency argument came from an era before dedicated swap instructions existed. Pick the order your protocol or your neighbours already use.

9. How to tell an endianness bug from an ordinary bug

Checking your own machine is a one-liner: os.endianness() in Node, sys.byteorder in Python. Search engines print that answer above the results, and in most real debugging it is the wrong question anyway. When you parse a file or a packet, the format decides and your CPU is not involved. The skill worth having is recognising the symptom.

9.1 Two symptoms: the absurd number and the 256× number

The loud one is easy. Read the PNG width from section 2 the wrong way and you get 2,953,052,160 for a 1200-pixel image. Any field that should be a modest count and comes back in the billions is a reversed 32-bit integer until proven otherwise.

The quiet one is the expensive one. The bytes 00 00 01 00 read as 256 big-endian and 65,536 little-endian. Both look like plausible buffer sizes. Nothing throws, no assertion fires, and the value is wrong by a factor of 256. Bugs like this survive code review because the number on screen looks reasonable. They belong to the same family as a UTF-8 BOM breaking a JSON parse: an invisible byte-level detail with a misleading error surface, covered in the UTF-8 BOM troubleshooting guide.

Two things are worth remembering. Small values with three leading zero bytes are the ones that flip quietly, because both readings stay in range. And if reversing the bytes by hand produces a number that makes sense, you have your answer without touching a debugger.

9.2 The order to check things in

  1. Check the format specification first. RFC 2083 says PNG is big-endian; RFC 1952 §2.3.1 says GZIP is little-endian. Whatever your machine does is irrelevant to both.
  2. Check your reader’s default second. DataView.getUint32(0) is big-endian, Uint32Array is platform order, struct.pack('@I', ...) is platform order, binary.BigEndian.Uint32 is whatever it says. Most byte-order bugs are a missing third argument or a missing prefix, not a deep misunderstanding.
  3. Suspect the platform last. It matters when you write a file with Uint32Array or @ and ship it to a different architecture, and it matters when you compare a memory dump against a spec. It almost never matters when you are reading a well-defined format that already told you which order it uses.

Frequently asked questions

Is big-endian or little-endian better?

Neither is better. Whichever the format specifies is the correct one for that format, and you rarely get to choose. On performance, a big-endian DataView read measured 1.021× the little-endian one on this machine, roughly 2%. That is far too small to drive any design decision.

How do I tell whether my machine is big-endian or little-endian?

os.endianness() in Node returns LE here, and sys.byteorder in Python returns little. Both are one-liners. The question matters less than it looks though: when you parse a file or a packet, the format dictates the byte order and your CPU has no say.

DataView and Uint32Array give different numbers from the same buffer. Is that a bug?

No — that is documented DataView behaviour. DataView.getUint32(0) defaults to big-endian, while Uint32Array always follows the platform, which is little-endian on x86 and Apple Silicon. Same bytes, two conventions. Pass true as the third argument and DataView will agree.

Why did my struct layout change size when I added <?

Because you switched away from @, the default, which pads for native alignment. struct.calcsize('@ci') is 8, while struct.calcsize('=ci') and struct.calcsize('<ci') are both 5. The three padding bytes before the int left along with native alignment.

Is network byte order big-endian or little-endian?

Big-endian. That is the convention TCP/IP headers use, and it is why htons and htonl exist in C. In Python the ! and > prefixes produce identical bytes: struct.pack('!I', 0x12345678) and struct.pack('>I', 0x12345678) both give 12 34 56 78.

Does endianness affect UTF-8?

No. UTF-8 is a byte stream, and each code point is written as an ordered sequence of individual bytes, so there is no multi-byte unit left to reorder. UTF-16 and UTF-32 do have that problem, and that is why they carry a BOM, as covered in section 7.

Do single-byte arrays need any byte-order handling?

No. Byte order only exists for units wider than one byte, so Uint8Array, Int8Array and Python bytes objects are immune. Widen by one step and it comes straight back: new Uint16Array(two)[0] = 0x00ff lands in memory as ff 00 on this machine.

Tags: endianness byte-order binary-data file-formats cross-platform

Related Articles

View all articles