skip to content

When is binary.NativeEndian the right choice in Go, and when is it a portability bug?

level: middleimportance: should knowfreq 30%

answer

  1. ask who else reads these bytes
  2. the format decides, not the CPU
  3. not every Go port is little-endian
  4. kernel structs and mmap, not frames
  5. an amd64 writer, an s390x reader

basics

~20 s

binary.NativeEndian encodes in whatever byte order the build target uses, so it fits only bytes that never leave the machine. Anything written to a wire or a shared file must name binary.BigEndian or binary.LittleEndian instead.

solid answer

~40 s

All three values satisfy `binary.ByteOrder`, but they answer different questions. `binary.BigEndian` and `binary.LittleEndian` state an order the *format* defines, so both ends agree no matter which GOARCH each was built for. `binary.NativeEndian` is whatever the current build target happens to use — little-endian on amd64, arm64, 386 and arm; big-endian on s390x, ppc64, mips and mips64. That makes it right for host-local bytes: a struct handed over by the kernel, a memory-mapped region, a scratch file you are willing to throw away. It becomes a bug the moment those bytes cross a machine boundary, because a frame written by an amd64 collector decodes to byte-swapped garbage on a big-endian consumer. A wire format should pick one order, write it through the named `ByteOrder`, and be covered by a test that asserts the exact bytes.

code

go · 7 lines
go
// 12-byte frame header, big-endian on the wire whatever GOARCH we built for
var hdr [12]byte
binary.BigEndian.PutUint32(hdr[0:4], uint32(len(payload)))
binary.BigEndian.PutUint64(hdr[4:12], uint64(sentAtUnixNano))

// NativeEndian is defensible only for bytes that never leave this machine
binary.NativeEndian.PutUint32(scratch[0:4], seq)

go deeper

for a junior

Know that a wire format must state its byte order and that binary.BigEndian or binary.LittleEndian is how you say it in Go. Being able to spot NativeEndian in a network encoder as suspicious is the expectation here.

for a middle

Explain that all three implement binary.ByteOrder, that NativeEndian is fixed at compile time by GOARCH, and name at least one big-endian target Go supports.

for a senior

Demonstrate the review instinct: ask who else reads the bytes, insist on byte-literal assertions rather than round-trip tests, and explain why a green suite proves nothing about ordering.

for a principal

Own the format itself. Decide that byte order is written into the protocol specification and enforced by golden-byte tests, so it is not rediscovered by each service that later joins the wire.

## What byte order means here, and where Go puts the choice A 32-bit integer occupies four bytes, and a machine has to decide which end goes first. Big-endian writes the most significant byte first; little-endian writes the least significant byte first. A value in a register or a Go variable has no byte order at all — the question only arises the instant the value is written into a `[]byte`, a file, or a socket. Go makes that moment explicit. `encoding/binary` defines the interface `binary.ByteOrder` with `Uint16`/`Uint32`/`Uint64` and the matching `PutUint*` methods, and ships implementations of it: - `binary.BigEndian` — always most-significant-first, whatever the build target is. - `binary.LittleEndian` — always least-significant-first, whatever the build target is. - `binary.NativeEndian` — whichever of the two the **build target** uses. The first two describe a *format*. The third describes a *machine*. Confusing those two is the entire bug. ## Go's supported architectures are not all little-endian It is comfortable and wrong to assume everything is little-endian. Among the ports the Go toolchain supports, amd64, 386, arm, arm64, riscv64, loong64, ppc64le and mips64le are little-endian, while **s390x, ppc64, mips and mips64 are big-endian**. The set is small, which is exactly why the assumption survives for years in a codebase and then fails once, at the worst moment, on the one target that does not share it. Note also that `NativeEndian` is fixed at compile time by `GOARCH`; it is not sniffed at startup, so cross-compiling silently changes what your encoder produces. ## When native order is genuinely correct There are real cases, and they share one property: the bytes never leave the machine that produced them. - Decoding a structure produced by the local kernel or a local device, whose layout is defined as the host's. - Reading or writing a memory-mapped region shared with another process on the same host. - A local scratch or cache file that you are willing to invalidate and rebuild — and that you actually *do* invalidate when the binary changes, because a host can be re-imaged onto a different architecture. Everything else — a network frame, an on-disk format two machines both read, an object in shared storage, anything a support engineer might decode by hand three years from now — needs a stated order in the specification and a named `ByteOrder` in the code. ## The worse cousin: reinterpreting the bytes A tempting "optimisation" is to point a `*uint32` at the start of a byte slice and read it directly. That buys you the same byte-order dependence *plus* an alignment dependence: on several 32-bit ports an unaligned multi-byte load is not something you may rely on, and the trick drags in `unsafe` with its own rules about what the garbage collector will and will not track. `binary.NativeEndian` expresses exactly the same intent with a plain byte-slice API that works at any offset, and `binary.BigEndian` expresses the intent you almost certainly wanted. ## Testing it without exotic hardware The test that catches an order mistake asserts **bytes**, not round trips. Encode a known value and compare against a byte literal; the test then fails on any target where the order slipped. An encode-then-decode round-trip test passes cheerfully on every architecture, because both halves of it share the same mistake — which is why so many of these bugs survive a green test suite and are discovered by the first consumer built for a different machine. ## In review When you see `binary.NativeEndian` in a diff, the only question worth asking is: who else reads these bytes? If the answer involves another machine, another architecture, or a future version of your own service, the order belongs in the format, not in the CPU.

  • Which architectures that Go supports are big-endian?
    s390x, ppc64 (the big-endian PowerPC port, as against ppc64le), mips and mips64. Everything else in common use — amd64, 386, arm, arm64, riscv64, loong64, ppc64le, mips64le — is little-endian. The list is short, which is why the 'everything is little-endian' habit survives so long and then fails exactly once.
  • Why is pointing a *uint32 at a byte slice worse than using binary.NativeEndian?
    It carries the same byte-order dependence and adds an alignment dependence: on several 32-bit ports you cannot rely on an unaligned multi-byte load, and the trick pulls in `unsafe` with its own rules. `binary.NativeEndian` states the same intent through a byte-slice API that works at any offset and needs no unsafe conversion.
  • How do you prove a frame encoder is byte-order correct without a big-endian machine?
    Assert the bytes, not the round trip. A test that encodes a known value and compares it against a byte literal fails wherever the order slipped. Encode-then-decode tests pass on every architecture because both halves share the same mistake, so they prove only that the code is self-consistent.

A stated byte order is a contract printed on the envelope; native order is a local habit. Writing the habit onto the wire works right up until the reader was built somewhere else.

saying these in an interview costs you the question

  • Says every machine Go runs on is little-endian
  • Uses binary.NativeEndian for a network frame
  • Tests only encode-then-decode round trips
  • Points a *uint32 at a byte slice for speed
  • Thinks the network stack swaps the bytes for you
  • Assumes NativeEndian is decided at run time