How to Use encoding/gob for Go-Specific Serialization

Serialize Go structs to binary streams using encoding/gob's Encoder and Decoder for efficient data persistence and transmission.

When JSON feels too heavy

You have a Go service sending data to another Go service. JSON works, but you're wasting bandwidth on field names, parsing strings back into integers, and losing type safety. You want something faster, smaller, and native. You reach for encoding/gob.

Gob is Go's built-in binary serialization format. It turns Go values into a stream of bytes that another Go program can reconstruct. Unlike JSON, gob doesn't embed human-readable text. It writes a compact binary representation optimized for Go types. Both sides must speak Go, but within that constraint, gob handles type matching, struct tags, and streams with zero configuration.

Gob is the Go-to-Go courier. It knows the layout of your structs, so you don't need to write labels on every box. If the layout changes, the courier adapts, provided both sides agree on the mapping.

How gob works

Gob serializes values by inspecting their types and emitting a binary description followed by the data. The encoder and decoder maintain a shared registry of types. When you encode a struct for the first time, gob sends a type description: field names, types, and tags. The decoder reads this and builds a map. Subsequent encodes of the same type skip the description and send only the data. This amortizes the cost. The first message carries overhead. The thousandth message is just raw data.

Gob streams are sequential. You can encode multiple values into one stream and decode them in order. The decoder maintains a cursor and reads values as they arrive. This makes gob useful for simple pipelines where you pump structs through a channel or file.

Gob only serializes exported fields. Private fields vanish. This is a design choice: gob respects Go's visibility rules. If a field starts with a lowercase letter, gob ignores it. If you need to serialize private data, you must export the field or implement the BinaryMarshaler interface.

Minimal round-trip

Here's the simplest gob workflow: encode a struct to a buffer, then decode it back. The buffer simulates a network connection or file.

package main

import (
	"bytes"
	"encoding/gob"
	"fmt"
)

type User struct {
	ID   int
	Name string
}

func main() {
	// Buffer holds the binary stream, acting as transport
	var buf bytes.Buffer

	// Encoder writes binary data into the buffer
	enc := gob.NewEncoder(&buf)
	// Decoder reads binary data from the same buffer
	dec := gob.NewDecoder(&buf)

	u := User{ID: 1, Name: "Alice"}

	// Encode flattens the struct into the byte stream
	if err := enc.Encode(u); err != nil {
		panic(err)
	}

	// Decode reconstructs the struct from the byte stream
	var u2 User
	if err := dec.Decode(&u2); err != nil {
		panic(err)
	}

	fmt.Printf("%+v\n", u2)
}

The encoder and decoder share state through the buffer. The encoder writes the type description for User, then the data. The decoder reads the description, learns what User looks like, and populates u2. If you encode User again, the encoder skips the description and writes only the data. The decoder reuses the cached type.

Gob is stateful. The encoder and decoder must stay in sync. If you create a new encoder and decoder pair, they start fresh. The type registry resets. This matters when you split encoding and decoding across different connections or restart services.

The type description trick

Gob's type description is self-describing but optimized. The first encode of a type sends metadata. Later encodes send only data. This means gob is slow for one-off messages and fast for streams of identical structs.

If you send a single User over the network, gob might be slower than JSON because of the type description overhead. If you send a thousand User structs, gob wins. The description cost is paid once. The data payload is compact.

This behavior creates an "ah-ha" moment: gob performance depends on message volume. Benchmarks that encode one value misrepresent gob's real-world speed. In production, services send repeated messages. Gob shines there.

Gob also supports struct tags to control the wire format. Tags let you rename fields, skip fields, or map Go names to shorter wire names. This helps when you want to optimize bandwidth or maintain compatibility across versions.

Realistic usage with tags

In production, you'll use struct tags to control the wire format and wrap errors for context. Tags map Go fields to wire names. The gob tag follows the same syntax as json tags.

package main

import (
	"bytes"
	"encoding/gob"
	"fmt"
)

// Message represents a chat payload with optimized field names
type Message struct {
	// Tag maps the Go field to a shorter wire name
	UserID int    `gob:"uid"`
	Text   string `gob:"txt"`
	// Tag with dash skips the field during serialization
	Temp   string `gob:"-"`
}

func sendMessage(msg Message) ([]byte, error) {
	var buf bytes.Buffer

	// Encoder targets the buffer for binary serialization
	enc := gob.NewEncoder(&buf)

	// Encode writes the type description and data to the buffer
	if err := enc.Encode(msg); err != nil {
		return nil, fmt.Errorf("encode message: %w", err)
	}

	return buf.Bytes(), nil
}

func receiveMessage(data []byte) (Message, error) {
	var msg Message

	// Decoder reads from the byte slice
	dec := gob.NewDecoder(bytes.NewReader(data))

	// Decode populates the struct, matching tags to fields
	if err := dec.Decode(&msg); err != nil {
		return msg, fmt.Errorf("decode message: %w", err)
	}

	return msg, nil
}

func main() {
	msg := Message{UserID: 42, Text: "Hello", Temp: "secret"}

	data, err := sendMessage(msg)
	if err != nil {
		panic(err)
	}

	received, err := receiveMessage(data)
	if err != nil {
		panic(err)
	}

	fmt.Printf("Received: %+v\n", received)
}

Tags control the wire name. gob:"uid" tells gob to write uid in the type description instead of UserID. The decoder matches uid to the UserID field. This lets you rename fields without breaking compatibility, as long as the wire name stays the same.

The gob:"-" tag skips a field. Temp is ignored during encoding and decoding. This is useful for transient data that shouldn't cross the boundary.

Error wrapping adds context. fmt.Errorf("encode message: %w", err) preserves the original error while adding a label. This follows Go's error handling convention: verbose by design, visible unhappy paths.

Pitfalls and traps

Gob has quirks that trip up newcomers. The compiler won't catch type mismatches. Gob checks types at runtime. If the receiver has a different struct definition, the decode fails with an error.

If you try to decode into a struct with mismatched fields, the decoder rejects the stream with gob: type mismatch. This happens when the encoded type description doesn't align with the receiver's struct. The error is runtime, not compile-time. You must test serialization across versions.

Gob encodes interfaces by writing the concrete type. This breaks abstraction. If you encode a Speaker interface backed by a Dog struct, gob writes Dog. The receiver must have Dog defined. If the receiver only knows Speaker, the decode fails with gob: type not found. Interfaces leak implementation details through gob.

package main

import (
	"bytes"
	"encoding/gob"
	"fmt"
)

type Speaker interface {
	Speak() string
}

type Dog struct {
	Name string
}

func (d Dog) Speak() string { return "Woof" }

func main() {
	var s Speaker = Dog{Name: "Rex"}

	var buf bytes.Buffer
	// Interface value encodes the concrete type Dog
	gob.NewEncoder(&buf).Encode(s)

	// Receiver must have Dog defined to decode successfully
	var s2 Speaker
	dec := gob.NewDecoder(&buf)
	if err := dec.Decode(&s2); err != nil {
		// Fails with gob: type not found if Dog is missing
		fmt.Println(err)
	}
}

Gob only sees exported fields. Private fields vanish. If your struct has id int, it won't round-trip. Rename to ID int or implement BinaryMarshaler. This is a common trap. Export your fields or lose them.

Gob is not human-readable. The output is a binary blob. You can't inspect it with cat or a text editor. If you need debugging visibility, use JSON. Gob is for machines, not humans.

Convention aside: gofmt formats gob code like any other Go code. No special rules. Run gofmt on save. The community expects consistent formatting. Don't argue about indentation; let the tool decide.

Decision matrix

Use encoding/gob when both sender and receiver are written in Go and you want zero-config serialization with automatic type handling.

Use encoding/json when you need human-readable output, interoperability with other languages, or debugging visibility.

Use Protocol Buffers when performance is critical, you need a strict schema, or you are communicating across language boundaries.

Use the BinaryMarshaler interface when you need full control over the binary format or want to optimize a specific struct's layout beyond what gob provides.

Gob is convenient. Protobuf is performant. Pick the tool that matches your boundary.

Where to go next