Networking

Serialization: Protobuf & Base64

Two ideas that get confused constantly because they're often used together: serialization turns a structured, in-memory object into a flat sequence of bytes (and deserialization turns it back); Base64 has nothing to do with that — it just disguises arbitrary binary bytes as plain text so they survive text-only channels. Protobuf is one specific, very common serialization format; Base64 is an encoding, not a format for structuring data at all.

Quick reference
What it is
Serialization: object → bytes (and back). Protobuf: a compact, schema-based binary serialization format. Base64: a text-safe encoding for arbitrary binary bytes.
Mental model
Serialization is packing a suitcase — structured contents into a flat, agreed-upon layout. Base64 isn't packing anything; it's writing the suitcase's contents out in a language (printable ASCII) that a text-only mail carrier is willing to transport.
Key numbers
Protobuf is typically 3–10× smaller than the equivalent JSON. Base64 makes data ~33% larger, never smaller.
Reach for it when
Protobuf — structured data crosses a process, network, or storage boundary and size/speed matter. Base64 — binary bytes must travel through something that only accepts text (a JSON string, a URL, an email body).

What serialization and deserialization actually mean

A running program keeps its data as objects, structs, or classes in memory — a layout full of things that are meaningless outside that one process: compiler-chosen padding between fields, pointers that are just addresses valid only in this process's address space, and a byte order (endianness) tied to the CPU it's running on. None of that survives being sent to another machine, written to a file, or even read by a different program on the same machine.

Serialization is the process of converting that in-memory structure into a flat, well-defined sequence of bytes that means the same thing to anyone who reads it, regardless of language, compiler, or CPU. Deserialization is the reverse: take that byte sequence and rebuild an equivalent in-memory structure on the receiving end.

An in-memory object is serialized into bytes, those bytes travel over a network or into storage, and the receiving side deserializes them back into an equivalent in-memory object In-memory object (sender) — struct / class instance Bytes wire format — Protobuf, JSON, ... serialize() network, disk, or IPC Bytes identical bytes, received intact In-memory object (receiver) — a new, equivalent copy deserialize() The receiver never sees the sender's memory — only these bytes, and a shared understanding of what they mean.
Serialization and deserialization are always a matched pair: whatever rules turned the object into bytes on the way out must be the exact same rules used to interpret the bytes on the way in.

Text vs. binary formats, at a glance

Base64 is deliberately left out of this table on purpose — it's covered later, but it is not a serialization format. It's an encoding that can be layered on top of any of these (or any binary data at all) when the transport only accepts text.

FormatEncodingNeeds a schema?Relative sizeHuman-readable?
JSONTextNo (optional, e.g. JSON Schema)LargestYes
XMLTextNo (optional, e.g. XSD)LargestYes
ProtobufBinaryYes — a .proto file, requiredSmallestNo — needs the schema to make sense of it
MessagePack / CBORBinaryNo — self-describing like JSON, just binarySmaller than JSONNo

Protocol Buffers (Protobuf)

Protobuf is a binary serialization format built around a schema you write yourself in a small interface-definition language, saved as a .proto file:

syntax = "proto3";

message SensorReading {
  int32  sensor_id  = 1;
  float  temperature = 2;
  string location    = 3;
}
Field notes

The most common thing a junior engineer misreads here: = 1, = 2, = 3 are field numbers, not default values and not array indices. They're permanent identifiers baked into every encoded message — see Schema evolution for why that matters.

A compiler (protoc) reads that .proto file and generates real code in whatever language you need — a class in Python or Java, a struct in C++, or (the option most relevant on a microcontroller) tiny C code via nanopb, a Protobuf implementation built specifically for embedded targets: no dynamic memory allocation, a tiny code footprint, and no C++ required. Whatever language it's generated in, the generated code gives you two functions that matter: one to serialize an instance into bytes, and one to parse bytes back into an instance.

# Python, using code generated from the .proto file above
reading = SensorReading(sensor_id=300, temperature=21.5, location="A1")
data = reading.SerializeToString()   # -> bytes, ready to send/store

# ... elsewhere, maybe on a different machine entirely ...
received = SensorReading()
received.ParseFromString(data)       # -> received.sensor_id == 300

How Protobuf actually encodes bytes

Unlike JSON, a Protobuf message on the wire never contains field names — only field numbers. Every field is written as a small tag byte (which field, and how its value is encoded) followed by the value itself:

tag = (field_number << 3) | wire_type

The wire type tells the parser how many bytes to read next: 0 for a variable-length integer ("varint" — used for int32, bool, enums), 5 for a fixed 4 bytes (float, fixed32), and 2 for "length-delimited" (a varint length, then that many raw bytes — used for string, bytes, and nested messages). Encoding SensorReading{ sensor_id: 300, temperature: 21.5, location: "A1" } from the schema above looks like this on the wire:

Three Protobuf fields encoded back to back as tag-and-value pairs: a varint field, a fixed-32-bit field, and a length-delimited string field sensor_id = 1 (varint) 0x08 tag AC 02 varint 300 temperature = 2 (32-bit) 0x15 tag 4 bytes IEEE-754 float 21.5 location = 3 (string) 0x1A tag 0x02 length "A1" bytes 12 bytes total on the wire — the equivalent JSON is about 47 bytes Nothing here says "sensor_id" or "temperature" — a parser without the .proto schema can see the tags but not their meaning.
Each field is just tag-then-value, back to back, with no delimiters between fields and no field names anywhere in the stream.

The varint for 300 (AC 02) is worth unpacking once: a varint spends 7 bits of each byte on the actual number and reserves the top bit to mean "another byte follows." 300 in binary is 1 00101100; split into 7-bit chunks from the low end that's 0101100 and 10. The first byte becomes 1_0101100 (continuation bit set) = 0xAC; the second becomes 0_0000010 (no more bytes) = 0x02. Small numbers (the overwhelmingly common case) fit in a single byte this way instead of always spending 4 bytes like a fixed-width int.

Schema evolution: why field numbers matter

In an IoT fleet, the firmware on a device and the schema the cloud expects are rarely upgraded in lockstep — some devices in the field may run firmware from years ago (see OTA at fleet scale). Protobuf is designed around that reality:

Base64 encoding and decoding

Plenty of systems are only willing to carry text — a JSON string value, a URL, an email body, an XML document. Raw binary bytes can contain values that those formats treat as control characters, delimiters, or just invalid — so they can't be dropped in directly. Base64 solves this by re-representing any sequence of bytes using only 64 characters that are always safe in text: A–Z, a–z, 0–9, +, and /.

The trick is a mismatch of sizes: a byte is 8 bits, but Base64 maps 6 bits at a time to one character (26 = 64 possible values, exactly the alphabet size). So 3 bytes (24 bits) divide evenly into 4 groups of 6 bits — 3 bytes in, 4 text characters out, which is why Base64-encoded data is always about 4/3 (~33%) larger than the original.

The three ASCII bytes of the string Hi! are regrouped from three 8-bit bytes into four 6-bit groups, each mapped to a Base64 character, producing the string SGkh Original bytes — 8 bits each 01001000 'H' (72) 01101001 'i' (105) 00100001 '!' (33) Regrouped into four 6-bit chunks (crossing byte boundaries) 010010 = 18 000110 = 6 100100 = 36 100001 = 33 Each 6-bit value looked up in the Base64 alphabet table S G k h Base64("Hi!") = "SGkh"
The dashed lines mark 6-bit Base64 boundaries against the solid 8-bit byte boxes above — they never line up, which is exactly why the bits need to be regrouped at all.

When the input length isn't a multiple of 3 bytes, the last group is padded with zero bits and the output string is padded with = characters to keep every encoded block a multiple of 4 characters — e.g. Base64("Hi") (2 bytes) comes out as "SGk=", one = short of a full 4-character group. Decoding just runs the whole diagram in reverse: characters back to 6-bit values, regrouped into 8-bit bytes, padding discarded.

Real-world applications

These two ideas show up together constantly in IoT systems — a device that measures data, serializes it compactly, then has to squeeze that binary payload through a transport that only speaks text (a REST/JSON API is extremely common even when the payload inside it isn't JSON-native):

A sensor reading is protobuf-serialized, base64-encoded into a JSON payload, sent over HTTPS, then base64-decoded and protobuf-deserialized on the cloud side Sensor struct on the device Protobuf bytes compact, binary Base64 text inside a JSON field Protobuf bytes decoded, on the cloud Sensor struct on the server protobuf serialize base64 encode HTTPS POST + base64 decode protobuf deserialize The device and server never touch each other's memory — only this chain of well-defined byte transformations.
Neither step alone would be enough: Protobuf alone can't survive a text-only field, and Base64 alone doesn't structure the data at all.

Gotchas

Field notes