When bytes need to speak a common language
You are building a client that talks to a legacy service written in C. The service sends a 4-byte header followed by a payload. You read the header into a byte slice, convert it to an integer, and the number is completely wrong. The bytes are there. The type is correct. The only thing missing is a shared agreement on which byte comes first.
Network protocols, file formats, and hardware registers all store multi-byte numbers as a sequence of individual bytes. The question is whether the most significant byte goes first or last. Big-endian puts the largest value first, like reading a book from left to right. Little-endian puts the smallest value first, like stacking plates where the newest one lands on top. The encoding/binary package gives you explicit control over this ordering. You pick the byte order once, then use it to pack and unpack integers, floats, and bit arrays without guessing.
Endianness is not a bug. It is a contract.
How endianness shapes your data
A uint32 occupies exactly 4 bytes. If you store the value 0x01020304 in memory, those four bytes must live somewhere. Big-endian layout stores them as 01 02 03 04. Little-endian layout stores them as 04 03 02 01. The numeric value is identical. The memory representation is reversed.
Go does not guess your byte order. The language itself is platform-aware for native memory, but the encoding/binary package forces you to declare your intent. You interact with two predeclared variables: binary.BigEndian and binary.LittleEndian. Both implement the binary.ByteOrder interface, which defines methods like Uint16, PutUint32, Float64, and AppendUint64. The interface pattern is a quiet piece of Go design. It lets you pass a byte order around as a parameter instead of hardcoding if statements throughout your codebase.
The package mutates the slice you pass in. It never allocates behind your back. This keeps garbage collection pressure low when you are packing thousands of packets per second.
The package mutates your slice. It never allocates behind your back.
The minimal example
package main
import (
"encoding/binary"
"fmt"
)
// EncodeNumber converts a uint64 to a byte slice and reads it back.
func EncodeNumber() {
// Create a buffer large enough to hold exactly one uint64.
// Pre-allocating avoids reallocation and keeps memory predictable.
buf := make([]byte, 8)
// Store the number in little-endian order.
// The function writes directly into the provided slice.
binary.LittleEndian.PutUint64(buf, 42)
// Read the value back using the same byte order.
// Mismatched endianness would produce a completely different number.
result := binary.LittleEndian.Uint64(buf)
fmt.Println(result) // Prints 42
}
func main() {
EncodeNumber()
}
The code above shows the core loop: allocate, write, read. The PutUint64 call takes the slice and the integer, splits the integer into 8 bytes, and places them at indices 0 through 7. The Uint64 call reverses the process. It reads exactly 8 bytes, reconstructs the integer, and returns it. If you pass a slice that is too short, the program panics at runtime. The compiler cannot catch this because slice length is a runtime value.
What happens under the hood
When you call binary.LittleEndian.PutUint64(buf, 42), the runtime executes a tight loop that shifts and masks the integer. It extracts the lowest 8 bits, writes them to buf[0], shifts the integer right by 8, extracts the next 8 bits, writes them to buf[1], and repeats until all 8 bytes are placed. Big-endian does the same work but writes from the end of the slice backward, or shifts from the most significant bits first. The exact implementation varies by architecture, but the contract is identical.
The package also provides AppendUint64 methods. These take a slice and return a new slice with the bytes appended. They are useful when you are building a buffer incrementally and do not want to manage indices manually. The compiler inlines these functions aggressively, so the performance cost is negligible.
If you forget to allocate enough space and call PutUint64 on a 4-byte slice, the program crashes with panic: runtime error: slice bounds out of range [4:8]. The error tells you exactly where the boundary was crossed. You fix it by checking len(buf) >= 8 before the call, or by using AppendUint64 which handles reallocation for you.
The compiler rejects type mismatches early. If you accidentally pass a string instead of a []byte, you get cannot use s (variable of type string) as []byte value in argument. If you pass a uint32 to PutUint64, the compiler complains with cannot use x (variable of type uint32) as uint64 value in argument. These errors save you from silent data corruption.
Trust the bounds. Check the length. Let the compiler catch the types.
Reading and writing in the real world
Binary protocols rarely send a single number. They send structured headers. A typical packet might contain a magic number, a version field, and a payload length. You can pack these fields sequentially without relying on struct reflection or unsafe memory layout.
package main
import (
"encoding/binary"
"fmt"
)
// WritePacketHeader packs a magic number, version, and length into a byte slice.
// It returns the filled buffer for immediate transmission.
func WritePacketHeader(buf []byte, magic uint32, version uint16, length uint32) {
// Verify the buffer has exactly 10 bytes to hold all fields.
// 4 bytes for magic + 2 bytes for version + 4 bytes for length.
if len(buf) != 10 {
panic("header buffer must be exactly 10 bytes")
}
// Write the magic number at offset 0 using big-endian order.
// Network protocols traditionally use big-endian for consistency.
binary.BigEndian.PutUint32(buf[0:4], magic)
// Write the version at offset 4.
// The slice index advances to avoid overwriting the magic number.
binary.BigEndian.PutUint16(buf[4:6], version)
// Write the payload length at offset 6.
// This field tells the receiver how many bytes to expect next.
binary.BigEndian.PutUint32(buf[6:10], length)
}
// ReadPacketHeader unpacks a 10-byte buffer into its three components.
// It returns the magic, version, and length values for validation.
func ReadPacketHeader(buf []byte) (uint32, uint16, uint32) {
// Ensure the buffer is large enough before reading.
// Reading past the end causes a runtime panic.
if len(buf) < 10 {
panic("buffer too small for packet header")
}
// Extract the magic number from the first 4 bytes.
magic := binary.BigEndian.Uint32(buf[0:4])
// Extract the version from bytes 4 and 5.
version := binary.BigEndian.Uint16(buf[4:6])
// Extract the length from the final 4 bytes.
length := binary.BigEndian.Uint32(buf[6:10])
return magic, version, length
}
func main() {
buf := make([]byte, 10)
WritePacketHeader(buf, 0xCAFEBABE, 2, 1024)
magic, ver, len := ReadPacketHeader(buf)
fmt.Printf("Magic: %x, Version: %d, Length: %d\n", magic, ver, len)
}
The example shows manual offset management. You slice the buffer at each step to isolate the field you are packing or unpacking. This approach is explicit, fast, and immune to struct padding surprises. Go does not guarantee struct field alignment across platforms. If you use unsafe to cast a struct to bytes, you will get platform-dependent padding. The encoding/binary package avoids that trap entirely.
Pack your header. Send the payload. Keep the contract tight.
Pitfalls and compiler guardrails
Binary conversion looks simple until you hit edge cases. The most common mistake is assuming the package handles alignment. It does not. If you are reading a C struct that contains a uint16 followed by a uint32, the C compiler may insert 2 bytes of padding between them. Go's encoding/binary will read the bytes sequentially and interpret the padding as part of the next field. You must account for padding manually, or use a library like golang.org/x/sys/unix that understands platform-specific struct layout.
Another trap is mixing byte orders in the same buffer. If you write the first field with LittleEndian and the second with BigEndian, the reader will decode garbage unless it knows exactly where the switch happens. Document your wire format. Stick to one order per protocol.
The compiler will not save you from logical errors, but it will catch type mismatches. If you try to pass a []int8 instead of []byte, you get cannot use s (variable of type []int8) as []byte value in argument. If you forget to import the package, you get undefined: binary. If you import it and never use it, the compiler rejects the file with imported and not used. These rules keep your codebase clean and force you to acknowledge every dependency.
Convention matters here too. Go developers pre-allocate buffers to the exact size needed. They avoid append in hot paths unless they are using the Append* methods. They name their byte order variables explicitly instead of hardcoding binary.BigEndian everywhere. If you need to swap endianness for testing, you pass a binary.ByteOrder interface to your functions. The interface pattern pays off when you write unit tests that verify both big and little endian paths without duplicating code.
Check your buffer length before you write. A panic is just a boundary you forgot to enforce.
Choosing your byte order and tools
Use encoding/binary when you need explicit control over byte order and want zero-allocation packing for network packets or file headers. Use fmt or strconv when you are working with human-readable text and do not care about binary layout or performance. Use a serialization library like protobuf or gob when your data structure is complex, requires versioning, or needs to cross language boundaries safely. Use unsafe or reflect only when you are interfacing with C code and must bypass Go's memory safety guarantees for performance.
Pick the right tool for the wire format. Do not serialize what you do not need to.