AlgoMaster Logo

gob Encoding

Low Priority18 min readUpdated June 6, 2026

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.

What gob Is and Why It Exists

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 CaseWhy gob Wins
Snapshotting an in-memory product catalog to diskSmaller and faster than JSON, both sides are Go
Persisting a customer's cart between sessions on the same Go serverNative 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 restartNo string-key overhead, faster decode on startup
Public REST API or any cross-language clientWrong 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 Encoder and Decoder

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.

Stream Semantics

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.

Cache a Product Catalog to Disk

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.

Encoding Interface Values

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().

Persisting a Cart Between Sessions

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.

Exported Fields Only

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.

Scenariogob Behavior
Sender has field Discount, receiver doesn't define itField is dropped on decode, no error
Receiver defines field Notes, sender didn't send itField is left at zero value, no error
Sender has Price int, receiver has Price float64gob converts compatible numeric types, no error
Sender has Price string, receiver has Price float64Decode 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.).

Custom Encoding with GobEncoder and GobDecoder

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.

gob over a TCP Connection

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.

gob vs JSON vs Protocol Buffers

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.

PropertygobJSONProtocol Buffers
Cross-language supportGo-only in practiceUniversalUniversal (with codegen)
Schema locationSelf-describing on the streamNone, name-basedSeparate .proto file, compiled to code
Wire formatBinaryTextBinary
Typical size vs JSON20 to 40% smallerBaseline30 to 70% smaller
Typical decode speed vs JSON2x to 5x fasterBaseline5x to 10x faster
Human-readable wireNoYesNo
StreamingYes, statefulYes, statelessYes (with framing)
Schema evolutionTolerant of missing/extra fieldsTolerant by nameTag-based, strict but versioned
Standard library supportYes (encoding/gob)Yes (encoding/json)No, separate google.golang.org/protobuf
Good for archivalNo, format is not stable across major Go versions in theoryYesYes
Required toolingNoneNoneprotoc compiler

The decision tree is roughly:

  1. If both ends are Go and you control them, gob is a strong default for performance and for use with net/rpc.
  2. If anything other than Go reads or writes the bytes, use JSON for simplicity or Protocol Buffers for performance.
  3. If you need long-term archival, do not pick gob. The format is documented and historically stable, but it was not designed for "I will read this file in 10 years on a future Go version." Pick a format with an explicit specification you trust to outlive your code.

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.

Pitfalls

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.

PitfallSymptomFix
Fresh encoder per messageDecoder fails or produces garbage after the first messageOne encoder per stream
Missing gob.Registergob: type not registered for interface at encode timeRegister concrete types in init()
Concurrent Encode on one encoderGarbled stream, decoder errorsSerialize writes via mutex or channel
Renaming exported fieldsField decoded as zero valueTreat field names as part of the wire format
Using gob for long-term archivalPossible decode failure on future Go versionsUse JSON or Protocol Buffers

Quiz

gob Encoding Quiz

10 quizzes