JSON is the right answer when two different systems need to talk to each other, but a lot of the time both ends of a wire (or a file) are written in Go. In that case JSON pays a cost it does not need to pay: every value is encoded as text, every number is reparsed, every field name is repeated. The encoding/gob package is Go's answer to "what if both sides know they are Go?" It is a self-describing binary format designed by the Go authors specifically for Go-to-Go communication, with first-class support for Go's type system. This lesson covers when to choose gob over JSON, how the encoder and decoder work, how to handle interfaces, custom encoding hooks, and the pitfalls that show up in production.
A gob is a binary representation of a Go value. The name comes from "stream of gobbets," which is the term the authors used for the chunks of data flowing between an encoder and a decoder. Each chunk carries a value plus, the first time a new type shows up, the schema needed to decode it. After that, the schema is cached on the wire and later values of the same type only carry their data.
The motivation is in the original Go blog post by Rob Pike. JSON and XML are fine, but they were not designed for Go. Field names are sent as strings on every record. Numbers are formatted as text and reparsed on the receiver. Sub-objects are nested using punctuation that has to be tokenized. None of that is expensive on its own, but in a Go-to-Go RPC system that sends millions of messages per minute, it adds up. gob is built around the assumption that both sides have access to the same Go type definitions, which lets the format be much more compact and much faster to decode.
Three properties matter most.
First, gob is self-describing. A gob stream starts with type information for every type it carries, so the decoder can reconstruct values without an out-of-band schema file. This is different from Protocol Buffers, where the schema lives in a .proto file that both sides must compile.
Second, gob is Go-native. It understands Go's type system directly: integers (sized and unsized), floating point, complex numbers, strings, byte slices, arrays, slices, maps, structs, pointers, and interfaces. It cannot encode channels or function values, since those are not meaningful outside the running program, but it handles everything else Go can express.
Third, gob is stream-oriented and stateful. Once you describe a type on a stream, subsequent values of that type just send their data. So an encoder and decoder are tied to a single stream from start to finish. You cannot pick gobs out of a stream in random order or have one decoder cherry-pick the third value.
Here is a one-line summary of when gob makes sense in an e-commerce backend.
| Use Case | Why gob Wins |
|---|---|
| Snapshotting an in-memory product catalog to disk | Smaller and faster than JSON, both sides are Go |
| Persisting a customer's cart between sessions on the same Go server | Native support for time.Time, slices of structs, no tag noise |
Go-to-Go RPC over TCP (the standard net/rpc package uses gob by default) | Designed for exactly this; stateful stream avoids re-sending schemas |
| Caching a denormalized order index for fast restart | No string-key overhead, faster decode on startup |
| Public REST API or any cross-language client | Wrong tool. Use JSON or Protocol Buffers instead. |
The last row is the most important. gob is not a serialization format for general interchange. If a Python service, a Java service, or a browser ever needs to read your data, gob is the wrong choice. There are no mature gob libraries outside Go.
The two types you work with are gob.Encoder and gob.Decoder. They wrap an io.Writer and an io.Reader respectively, so they compose with everything the standard library already gives you: files, network connections, buffers, gzip streams, encrypted pipes.
The encoder takes an io.Writer (&buf here, but it could be a file or a TCP connection just as easily). The decoder takes an io.Reader and writes the decoded value into a pointer you provide, exactly like json.Unmarshal. The method names mirror the JSON package on purpose: Encode and Decode, with the same signatures.
The decoder writes into the pointer you give it. Passing a value instead of a pointer produces an error at runtime.
Always pass &value.
Cost: The first encode of a new type pays a one-time cost for sending the type description. After that, values of the same type are very compact. For a struct with a few string and int fields, expect the first message to be 30 to 60 bytes larger than later ones. If you only encode one value per stream, that overhead dominates and JSON may actually be competitive.
An encoder and a decoder are not stateless helpers like json.Marshal. They are stateful objects tied to a stream. The first time a type is sent, the encoder writes a type description. The decoder reads that description and remembers it. Every later value of that type only carries its data, not its schema.
The mermaid diagram below shows what happens on a TCP connection between an inventory client and a Go-to-Go service. The first Encode(Product{...}) sends the schema and the data. Subsequent encodes of the same type only send data.
Because the schema is only sent once per type per stream, you have to keep the same encoder and decoder for the life of the connection. You cannot create a fresh gob.Encoder for every message and have the receiver decode it with a single long-lived decoder. The receiver will get a confused stream that looks like a fresh schema followed by data, followed by another fresh schema, followed by more data. It will probably decode for a while and then fail with a cryptic error.
The flip side is also true. You cannot pick the second value out of a gob stream without reading the first. The encoder and decoder are positionally coupled. There is no "skip to record 50" operation. If you need random access, gob is not the format you want; consider one record per file, or a binary format with a fixed-size index.
Here is the multi-value pattern that demonstrates the stream behavior. Imagine writing every order placed today into one gob file so a recovery tool can replay them on startup.
Three orders of two fields each, plus the schema, fit in 79 bytes. The reader loop is the standard pattern for any streaming decoder: keep calling Decode until you hit io.EOF, treating other errors as failures.
A common use is snapshotting in-memory state so a service can restart fast. Suppose a search service keeps a denormalized product catalog in memory, built from a slow upstream call. On shutdown you want to write the catalog to a file. On startup you want to read it back in a fraction of a second.
The whole catalog fits in one Encode call because gob handles slices natively. A reader on startup gets the slice back in one Decode. No tags, no field name strings repeated 27 times across three records, no JSON parsing overhead. For a 50,000-product catalog the difference between JSON and gob is measurable in startup time, often by hundreds of milliseconds.
Cost: gob is generally 2x to 5x faster than encoding/json for Go-to-Go decode of complex structs, and the encoded bytes are typically 20 to 40 percent smaller. The exact numbers depend on the data shape. The savings come from not parsing text and not allocating string keys for every field of every record.
This is the part that most newcomers get wrong. Interfaces in Go are dynamic, the concrete type is only known at runtime. When you encode an interface value, gob has to send not just the data but a name that tells the decoder which concrete type to construct. The decoder cannot guess; it needs the type to already be registered in its type registry.
You register concrete types with gob.Register. The convention is to call it from init() in the package that defines the type, so the registration happens before any encoder or decoder runs.
Consider an e-commerce order that can contain different kinds of items: a physical product, a digital download, a gift card. They all satisfy a common CartItem interface.
If you forget the gob.Register calls, the encoder fails with a message like gob: type not registered for interface: main.PhysicalProduct. The fix is straightforward: register every concrete type that can appear behind an interface value on either side of the stream.
The diagram below shows how the registration chain works. Both ends call gob.Register for every concrete type they expect. When a value is encoded, gob writes the type name on the wire. When the decoder reads the value, it looks up the name in its registry and constructs the right concrete type.
The shared step is critical. If the sender registers PhysicalProduct but the receiver does not, the receiver's decoder cannot construct the right type when it sees the name and will fail. Both processes must register the same set of types, which is one reason gob is for Go-to-Go: both sides usually import the same Go package and call the same init().
Here is a realistic use of gob beyond a single-process snapshot. A web service stores a customer's shopping cart on disk so the cart survives the customer logging out and back in later. When the customer returns, the server reads the gob file, hands the cart back to the front end, and the customer continues shopping.
Gob handles time.Time without any special casing on the calling side. The time.Time type implements GobEncoder and GobDecoder internally, so gob serializes it as a single opaque blob and the round trip preserves the time exactly, including the location. With JSON you would have to think about whether the time was formatted as RFC 3339 or as a Unix timestamp; with gob it just works.
Like JSON, gob only encodes exported (capital-letter) fields. Unexported fields are silently skipped. If you need to round-trip data through a struct, every field that should survive the trip must be exported.
The score field is unexported, so gob skips it on encode and leaves it at the zero value on decode. This is the same behavior as encoding/json, and the same fix applies: rename the field to Score if you want it to round-trip, or accept that internal state is not part of the on-the-wire format.
A related point: if the sender and receiver have different versions of the same struct, gob handles missing or extra fields gracefully. A field on the sender that the receiver does not define is dropped silently. A field on the receiver that the sender did not send is left at its zero value. This is more forgiving than Protocol Buffers, which has stricter rules about field tags, but more strict than JSON, which is purely name-based without any schema check.
| Scenario | gob Behavior |
|---|---|
Sender has field Discount, receiver doesn't define it | Field is dropped on decode, no error |
Receiver defines field Notes, sender didn't send it | Field is left at zero value, no error |
Sender has Price int, receiver has Price float64 | gob converts compatible numeric types, no error |
Sender has Price string, receiver has Price float64 | Decode fails with type mismatch error |
This makes gob reasonably tolerant of slow rolling upgrades, as long as no field changes its underlying kind (number, string, struct, slice, etc.).
When the default encoding is not what you want, you can implement two interfaces.
A type that satisfies these takes over its own representation on the wire, the same way MarshalJSON and UnmarshalJSON work for JSON. This is useful when you want a stable serialized form even if the internal struct evolves, or when you need to encode a type gob does not handle natively, like a value that includes a function or an unexported subsystem.
Here is a custom encoding for a customer email that is stored normalized (lowercase, trimmed) on the wire even if the in-memory representation has not been cleaned.
The encoder writes a normalized lowercase trimmed version. The decoder reads it as-is. Both sides agree on the wire format regardless of what was in memory on the encoder side. Note that GobEncode has a value receiver while GobDecode has a pointer receiver, because the decoder needs to write into the value. That asymmetry is exactly the same as MarshalJSON and UnmarshalJSON.
Use custom encoding sparingly. The default encoding handles 95 percent of cases well, and every custom encoder is a place where the in-memory and on-the-wire types can drift apart and create bugs. Add it when you have a concrete reason: a type gob can't handle, a stable serialized form that has to outlive the in-memory representation, or a security requirement to sanitize values before they hit disk.
The original motivation for gob was network RPC. Go's standard net/rpc package uses gob as its default codec. Even outside net/rpc, sending gobs over a raw TCP connection is the textbook example of a Go-to-Go service handoff.
Consider an inventory service. Clients connect over TCP, send a StockQuery for one or more SKUs, and the server replies with a StockReply carrying the quantity in stock. Both ends are Go.
The server creates one encoder and one decoder per connection and reuses them for the whole exchange. The client does the same. The schema for StockQuery is sent once on the first encode in each direction; later queries are just data. This is what gob's stateful streaming buys you: a low-overhead, schema-aware binary protocol on top of a plain TCP socket, written in maybe 40 lines of code.
Cost: Each Encode and Decode call holds an internal lock on the encoder or decoder. If you have multiple goroutines that need to send on the same connection, serialize the writes through a channel or a mutex. Concurrent unguarded calls to Encode on the same gob.Encoder will produce garbled output.
The most common question once people see gob is "should I just use this everywhere?" The answer is no, and the table below shows why.
| Property | gob | JSON | Protocol Buffers |
|---|---|---|---|
| Cross-language support | Go-only in practice | Universal | Universal (with codegen) |
| Schema location | Self-describing on the stream | None, name-based | Separate .proto file, compiled to code |
| Wire format | Binary | Text | Binary |
| Typical size vs JSON | 20 to 40% smaller | Baseline | 30 to 70% smaller |
| Typical decode speed vs JSON | 2x to 5x faster | Baseline | 5x to 10x faster |
| Human-readable wire | No | Yes | No |
| Streaming | Yes, stateful | Yes, stateless | Yes (with framing) |
| Schema evolution | Tolerant of missing/extra fields | Tolerant by name | Tag-based, strict but versioned |
| Standard library support | Yes (encoding/gob) | Yes (encoding/json) | No, separate google.golang.org/protobuf |
| Good for archival | No, format is not stable across major Go versions in theory | Yes | Yes |
| Required tooling | None | None | protoc compiler |
The decision tree is roughly:
net/rpc.For typical e-commerce backends, the right mix is JSON for the public API (browser, mobile apps, third-party integrations), and gob for internal Go-to-Go traffic and local caches.
A few mistakes show up repeatedly in gob code.
Reusing an encoder across requests but recreating the decoder. This is the asymmetric version of the earlier streaming pitfall. If the sender uses one long-lived encoder but the receiver creates a fresh decoder for every message, the decoder will fail on every message except the first because it expects a fresh schema header that the encoder is no longer sending. Both sides must use the same lifecycle: one encoder per stream, one decoder per stream, both for the whole conversation.
Encoding a value of one type, decoding into a different type. gob does not have a schema-checking step that fails fast on incompatible structures. If you encode a Product and decode into a Customer, you may get a confusing partial result or a cryptic error depending on field alignment. Always decode into the same type you encoded, or into a type designed to be a compatible projection.
Forgetting `gob.Register` for interface values. The error message is good (gob: type not registered for interface), but it only shows up at encode time, which can be in a hot path far from where you would expect a registration bug. The fix is to register every concrete type in init() in the package that owns the interface, not scattered across the application.
Treating gob as an archival format. gob's documentation says the format may change in future major Go versions. In practice, it has been stable since Go 1.0, but "in practice stable" is not a guarantee. Do not write data to a gob file with the intent of reading it 5 years later on a future Go version. If you need long-term storage, JSON or Protocol Buffers (with explicit version numbers in the schema) is the safer choice.
Concurrent calls to Encode on the same encoder. A gob.Encoder is not safe for concurrent use. If two goroutines call Encode at the same time on the same encoder, the streams interleave and the receiver sees corruption. Serialize through a channel, a mutex, or one encoder per goroutine. The same applies to gob.Decoder.
Type changes that look minor but aren't. Renaming a field from Total to Amount changes the on-the-wire field name, so old gob bytes won't restore into the new struct cleanly (the old field is dropped, the new field stays at zero). Adding or removing a field is usually safe, but renaming or changing the underlying kind (int to string) breaks the round trip. Treat the field names in your serialized structs as part of the API.
| Pitfall | Symptom | Fix |
|---|---|---|
| Fresh encoder per message | Decoder fails or produces garbage after the first message | One encoder per stream |
Missing gob.Register | gob: type not registered for interface at encode time | Register concrete types in init() |
| Concurrent Encode on one encoder | Garbled stream, decoder errors | Serialize writes via mutex or channel |
| Renaming exported fields | Field decoded as zero value | Treat field names as part of the wire format |
| Using gob for long-term archival | Possible decode failure on future Go versions | Use JSON or Protocol Buffers |
10 quizzes