- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have two Go services that talk only to each other. A scheduler
that hands work to a pool of workers. A cache node that ships entries
to a peer. Both ends are Go. Both ends share the same structs, often
from the same internal module. And yet the payload on the wire is
JSON, because JSON is the default everyone reaches for.
There is a format in the standard library built for exactly this
case. encoding/gob is Go's own binary serialization. It was written
for Go programs talking to Go programs, and when that is your whole
world, it does things JSON cannot. It also has one caveat sharp
enough that reaching for it in the wrong place will hurt. This post
is about both.
What gob actually is
gob streams typed values between an Encoder and a Decoder. The
encoder writes a self-describing binary stream: the type definition
travels with the data the first time a type appears, so the decoder
knows how to reconstruct the value without a shared schema file.
package main
import (
"bytes"
"encoding/gob"
"fmt"
)
type Job struct {
ID int
Payload string
Priority uint8
}
func main() {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
dec := gob.NewDecoder(&buf)
if err := enc.Encode(Job{ID: 7, Payload: "resize", Priority: 3}); err != nil {
panic(err)
}
var got Job
if err := dec.Decode(&got); err != nil {
panic(err)
}
fmt.Printf("%+v\n", got)
}
Run it and you get {ID:7 Payload:resize Priority:3}. No struct
tags, no field annotations, no code generation step. You point an
encoder at an io.Writer and a decoder at an io.Reader, and Go
handles the rest.
The stream is stateful. Once a type crosses the wire, its definition
is not sent again on the same stream. So a long-lived connection that
sends a thousand Job values pays the type-description cost once, not
a thousand times. That is a real difference from JSON, where every
message re-sends every field name as text.
gob vs JSON for internal RPC
JSON earns its place at the edges of your system. Browsers speak it,
other-language services speak it, humans read it in a log. None of
that matters between two Go processes you own on both ends.
Here is where gob pulls ahead for the internal case:
-
No field names on the wire. JSON writes
"priority"as five bytes of text on every message. gob sends field numbers after the first type description. Repeated messages get smaller. -
Types the way Go means them. A JSON number decodes to
float64unless you fight it, which quietly breaks largeint64values. gob preservesint64,uint8,time.Time, and the rest with no loss and no tags. - No reflection tax you can see. gob caches the encoding plan per type after first use, so a hot loop over the same struct is not re-deriving anything.
Where JSON still wins: anything crossing a language boundary, any
payload a human needs to read, any public API, and anything you want
to curl. If a non-Go client might ever read the bytes, gob is the
wrong tool. That is not a knock on gob. It is the boundary it was
drawn for.
A rough decision line: gob for the wire between services you deploy
together and update together. JSON for the wire between you and
anyone else.
Registering interface types
Structs with concrete fields work with zero setup. The moment a field
is an interface, gob needs help, because the decoder has to know which
concrete type to build behind the interface.
Say your Job carries a Payload that can be one of several shapes:
type Payload interface {
Kind() string
}
type ResizeImage struct {
Width, Height int
}
func (ResizeImage) Kind() string { return "resize" }
type SendEmail struct {
To string
Subject string
}
func (SendEmail) Kind() string { return "email" }
type Job struct {
ID int
Payload Payload
}
Encode a Job whose Payload is a ResizeImage and gob will fail
at decode time with a message about the concrete type not being
registered. The fix is gob.Register, called once at startup for
every concrete type that can travel through an interface field:
func init() {
gob.Register(ResizeImage{})
gob.Register(SendEmail{})
}
Now the encoder writes the concrete type's name into the stream, and
the decoder looks it up in the registry to build the right value.
func main() {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
dec := gob.NewDecoder(&buf)
out := Job{ID: 1, Payload: ResizeImage{Width: 800, Height: 600}}
if err := enc.Encode(out); err != nil {
panic(err)
}
var in Job
if err := dec.Decode(&in); err != nil {
panic(err)
}
fmt.Println(in.Payload.Kind()) // resize
}
Two rules keep this honest. Register the concrete type, not a pointer,
unless you actually send pointers, in which case register the pointer
form too. And put every gob.Register call in the same package on
both ends, so the encoder and decoder agree on the registered set.
The registry is process-global, so registering the same type twice is
fine, but registering two different types under the same name will
panic.
Versioning fields across deploys
Internal services drift. One side ships a new field on Monday, the
other side rolls out Wednesday. gob is built to tolerate that gap, and
the rules are worth knowing before you rely on them.
gob matches fields by name, not by position. That gives you a
forgiving contract:
- A field the decoder does not have is skipped. The encoder can send a struct with an extra field to an old decoder and the old decoder ignores it.
- A field the encoder did not send is left at its zero value on the decoder. A new decoder reading old data gets the zero value for the new field.
- Renaming a field breaks the match. The old name is skipped, the new name arrives at its zero value. A rename is a remove plus an add, so treat it that way.
Picture the encoder side one deploy ahead:
// v2 encoder
type Job struct {
ID int
Payload string
TenantID string // new in v2
}
// v1 decoder, not yet deployed
type Job struct {
ID int
Payload string
}
The v1 decoder reads the v2 stream fine. TenantID is not in its
struct, so gob drops it on the floor. No error. When v1 rolls forward
to v2, TenantID starts arriving and populating. Additive changes are
safe in both directions, which is what you want for a rolling deploy.
The trap: type changes are not additive. If Priority was uint8 in
v1 and you widen it to int in v2, gob will refuse a value that does
not fit or will error on the type mismatch. Changing a field's type is
a breaking change on the wire. Add a new field and migrate rather than
mutating an existing one, exactly as you would with a database column
you cannot afford to break.
If you need fields that never go out, keep them exported. gob ignores
unexported fields entirely. An unexported field is not an error, it
just never crosses the wire and stays at its zero value on the far
side, which surprises people who expected it to travel.
The caveat that decides everything: gob is not cross-language
This is the line that sends people back to JSON, and it should. gob
has no meaningful implementations outside Go. The wire format is
documented, but there is no maintained gob library for Python, Java,
Rust, or JavaScript that you would want in production.
So the boundary is strict. If the byte stream will ever be read by
something that is not a Go program built from compatible types, gob
is the wrong format. That covers public APIs, browser clients,
polyglot service meshes, message queues that other teams consume, and
anything you archive to disk and might read years later from a
different stack.
Inside a fleet of Go services you own end to end, none of that
applies, and gob's downsides evaporate. The version-tolerance rules
above assume both ends share the same Go types anyway, so the format's
Go-only nature is a description of its home, not a limitation you fight.
One more practical note: gob is a Go-runtime format, not a stable
long-term storage format. The team maintains wire compatibility across
Go versions, but treating a gob blob as a decade-long archive format is
asking more of it than it promises. For durable storage that outlives
your current binaries, pick a format with a schema you control.
When to reach for it
Reach for gob when both ends are Go, you deploy them together, and the
payload is internal plumbing rather than a public contract. A worker
pool, a cache-replication link, a snapshot passed between replicas of
the same service, an RPC layer where you own the client and the
server. In those places gob gives you typed, compact, tag-free
serialization out of the standard library with no dependencies to
vendor.
Reach for JSON, protobuf, or anything with a cross-language story the
instant a non-Go reader enters the picture. The test is one question:
will anything that is not your Go code ever read these bytes. If yes,
gob is out. If no, gob is often the least-effort format that also
happens to be the fastest to wire up.
If this was useful
gob sits right on the seam between Go's runtime and Go's type system,
which is why it rewards knowing how the language represents values
underneath. The Complete Guide to Go Programming goes deep on that
layer: reflection, interface internals, and how the encoding packages
build their plans, so the behavior above stops feeling like magic.
Hexagonal Architecture in Go is the companion for keeping a choice
like gob where it belongs: behind an adapter at the edge of your
service, so swapping the wire format later never leaks into your core.

Top comments (0)