Binary files are just bytes
You are building a game engine and need to save a chunk of memory to disk. Or you are implementing a custom network protocol and need to persist a packet. Text files are convenient for humans. They have encodings, line endings, and invisible control characters that change depending on the operating system. Binary files are different. They are raw bytes. What you write is exactly what you read. No translation layer. No surprises.
Go treats all files as streams of bytes. There is no "binary mode" flag like in Python or C. A file is a file. The difference lies in how you interpret the data. When you read a text file, you decode bytes into strings. When you read a binary file, you keep the bytes as []byte or pack them into structs. The file system does not care. The kernel moves bytes from disk to memory. Your job is to manage those bytes correctly.
The core concept
Binary I/O in Go revolves around three things: file handles, byte slices, and the io package interfaces.
An os.File is a handle to an open file. It implements io.Reader and io.Writer. These interfaces are the backbone of Go's I/O. io.Reader defines a Read([]byte) (int, error) method. io.Writer defines a Write([]byte) (int, error) method. Because os.File implements these, you can pass file handles to any function that accepts readers or writers. This makes your code flexible. You can write a function that processes binary data from a file, a network socket, or a memory buffer using the same logic.
Byte slices, []byte, are the workhorse. They hold the raw data. When you write to a file, you pass a slice. When you read, you fill a slice. The length of the slice determines how much data you move.
Files are streams. Treat them as such.
Minimal example
Here is the simplest way to write binary data and read it back. You create a file, write a few bytes, close the file, open it again, and read the bytes into a buffer.
// main writes a 4-byte signature and reads it back
package main
import (
"fmt"
"io"
"os"
)
func main() {
// sig holds the raw bytes we want to persist
sig := []byte{0x89, 0x50, 0x4E, 0x47}
// Create opens the file for writing, truncating it if it exists
f, err := os.Create("magic.bin")
if err != nil {
fmt.Println(err)
return
}
// defer ensures the file handle closes even if we panic later
defer f.Close()
// Write sends the bytes to the OS buffer
_, err = f.Write(sig)
if err != nil {
fmt.Println(err)
return
}
// Open re-opens the file for reading
f, err = os.Open("magic.bin")
if err != nil {
fmt.Println(err)
return
}
// Close again since we got a new handle
defer f.Close()
// buf allocates space for exactly 4 bytes
buf := make([]byte, 4)
// ReadFull blocks until it fills buf or hits EOF
_, err = io.ReadFull(f, buf)
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("Signature: %02x\n", buf)
}
The output is Signature: 89504e47. The bytes match what we wrote.
What happens under the hood
When you call os.Create, Go asks the operating system to create a new file or truncate an existing one. The OS returns a file descriptor. Go wraps this in an os.File struct.
When you call f.Write(sig), Go copies the bytes from your slice into a kernel buffer. The Write method returns the number of bytes written and an error. If the return count is less than the length of the slice, the write was partial. This can happen with slow devices or interrupted system calls. For small writes, Write usually succeeds completely. For large writes, you should loop until all bytes are written or use io.Copy.
When you call os.Open, you get a new handle for reading. The file pointer starts at the beginning.
io.ReadFull is safer than f.Read. f.Read returns as soon as it has at least one byte. It might return fewer bytes than your buffer size. io.ReadFull loops internally until your buffer is completely filled or an error occurs. If the file ends before the buffer is full, io.ReadFull returns io.ErrUnexpectedEOF. This protects you from partial reads that corrupt your data.
Buffering is free performance. Use it.
Realistic example: Structs and endianness
Writing raw byte slices works for simple data. Real applications often need to store structured records. You might have a header with a magic number, a version, and a length. Go's encoding/binary package handles this. It converts structs to and from bytes.
Endianness matters. Endianness is the order in which bytes are stored for multi-byte values. Big-endian stores the most significant byte first. Little-endian stores the least significant byte first. Network protocols usually use big-endian. x86 CPUs use little-endian. If you write data on one machine and read it on another with a different endianness, the numbers will be wrong. encoding/binary lets you specify the byte order explicitly.
Here is how you write a struct to a binary file.
// Header represents a fixed-size binary record
type Header struct {
Magic uint32
Version uint16
Length uint32
}
func writeHeader(path string, h Header) error {
// Create opens the file with default permissions
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
// binary.Write converts the struct to bytes using the specified byte order
return binary.Write(f, binary.LittleEndian, &h)
}
binary.Write takes a writer, a byte order, and a pointer to the value. It marshals the struct fields into bytes and writes them. The struct must have exported fields. Unexported fields are skipped. You can use struct tags to control the layout, but the default behavior packs fields sequentially.
Reading is symmetric. You use binary.Read.
func readHeader(path string) (Header, error) {
var h Header
// Open the file for reading
f, err := os.Open(path)
if err != nil {
return h, err
}
defer f.Close()
// binary.Read unmarshals bytes from the file into the struct
err = binary.Read(f, binary.LittleEndian, &h)
return h, err
}
binary.Read reads enough bytes to fill the struct. It respects the byte order. If the file is too short, it returns an error.
Endianness is a silent killer. Pick one and stick to it.
Pitfalls and errors
Binary I/O has traps. The compiler cannot catch them. You must handle errors and edge cases.
Partial reads and writes. Read and Write can return fewer bytes than requested. This is normal for streams. If you use Read directly, you must check the return count. If you need exactly N bytes, use io.ReadFull. If you need to write exactly N bytes, use io.WriteFull or loop.
EOF handling. io.EOF is not an error in all contexts. When reading from a stream, Read returns io.EOF when there is no more data. If you have read some data before EOF, that is success. If you read zero bytes and get EOF, the stream is closed. io.ReadFull treats EOF as an error if the buffer is not full. Know which function you are using.
Defer timing. Always call defer f.Close() immediately after checking the error from Open or Create. If you defer before the error check, you might defer closing a nil file handle, which panics. Or you might leak the handle if the open fails and you return early.
f, err := os.Open("data.bin")
if err != nil {
return err
}
defer f.Close()
This pattern is standard. The community accepts the boilerplate because it makes the unhappy path visible.
File permissions. os.Create uses default permissions, usually 0666, modified by the umask. If you need specific permissions, use os.OpenFile with os.O_CREATE | os.O_WRONLY | os.O_TRUNC and a mode like 0644.
Memory usage. Reading a large file into a single []byte can exhaust memory. Use bufio.Reader to read in chunks. Or use io.Copy to stream data from one place to another without loading it all into RAM.
The worst goroutine bug is the one that never logs. The worst I/O bug is the one that silently corrupts data. Check your lengths.
Decision matrix
Binary I/O has many tools. Pick the right one for the job.
Use os.ReadFile when the file fits in memory and you need the whole thing at once. It handles opening, reading, and closing in one call. It returns the entire file as a []byte.
Use os.WriteFile when you have a []byte and want to write it to a file atomically. It creates the file, writes the data, and closes it. It is safe for small payloads.
Use bufio.Reader when you are streaming data and want to reduce system calls. It buffers reads from the underlying file. You can read line by line or in fixed-size chunks without hitting the disk every time.
Use encoding/binary when you need to pack structs into fixed-size records. It handles endianness and type conversion. It is ideal for file headers and protocol messages.
Use io.Copy when you need to move data from a reader to a writer. It handles buffering and chunking automatically. It is the standard way to copy files or stream responses.
Use raw []byte slices and Read/Write when you are working with variable-length payloads or custom protocols that do not fit struct layouts.
Use mmap (via golang.org/x/exp/mmap) when you need to access large files as memory without copying. It maps the file into the address space. It is advanced and requires careful handling of memory limits.
Trust the standard library. It handles the edge cases.