Serialization Frameworks: Protocol Buffers, FlatBuffers, and Cap’n Proto
Introduction
Serialization frameworks convert structured data into byte streams for storage or network transmission. On Linux, three high-performance binary serialization formats dominate: Protocol Buffers (protobuf) by Google, FlatBuffers by Google, and Cap’n Proto by Kenton Varda (original protobuf author). Each makes different trade-offs between encoding speed, decoding speed, wire size, and zero-copy access.
This guide compares their architectures, shows Linux-specific usage patterns, and provides benchmarks to help choose the right format for your system.
Why Binary Serialization?
Text formats like JSON and XML are human-readable but expensive to parse:
JSON parse cost (relative):
┌──────────────┬───────────┬───────────┬──────────┐
│ Operation │ JSON │ Protobuf │ FlatBuf │
├──────────────┼───────────┼───────────┼──────────┤
│ Serialize │ 1x │ 5-10x │ 8-15x │
│ Deserialize │ 1x │ 3-6x │ 20-50x │
│ Wire size │ 1x │ 0.3-0.5x │ 0.4-0.7x │
│ Random field │ O(n) scan │ O(n) scan │ O(1) │
└──────────────┴───────────┴───────────┴──────────┘
(Values approximate, higher is better for serialize/deserialize)
Architecture Comparison
graph TB
subgraph "Protocol Buffers"
PB_SCHEMA[".proto schema"] --> PB_CODE["Generated code"]
PB_CODE --> PB_ENC["Encode: build message"]
PB_CODE --> PB_DEC["Decode: parse all fields"]
PB_ENC --> PB_WIRE["Compact varint wire format"]
PB_DEC --> PB_WIRE
end
subgraph "FlatBuffers"
FB_SCHEMA[".fbs schema"] --> FB_CODE["Generated code"]
FB_CODE --> FB_BUILD["Build: construct in buffer"]
FB_BUILD --> FB_WIRE["Buffer IS the data"]
FB_WIRE --> FB_ACCESS["Direct field access<br>No parsing needed"]
end
subgraph "Cap'n Proto"
CP_SCHEMA[".capnp schema"] --> CP_CODE["Generated code"]
CP_CODE --> CP_BUILD["Build: construct in buffer"]
CP_BUILD --> CP_WIRE["Buffer IS the data"]
CP_WIRE --> CP_ACCESS["Direct field access<br>Pointer-based"]
end
style PB_WIRE fill:#3182ce,color:#fff
style FB_WIRE fill:#38a169,color:#fff
style CP_WIRE fill:#d69e2e,color:#000
Protocol Buffers
Overview
Protocol Buffers (protobuf) is Google’s language-neutral, platform-neutral serialization
format. It uses a schema (.proto files) to generate efficient serialization code.
Protobuf prioritizes compact wire format and broad language support over
zero-copy access.
Key properties:
- Varint encoding for integers (saves space)
- Tag-length-value wire format
- Full deserialization required before field access
- Backward/forward compatible via field numbers
- Supports 50+ languages
- Mature ecosystem (gRPC, gRPC-Web)
Schema Definition
// person.proto
syntax = "proto3";
package tutorial;
message Address {
string street = 1;
string city = 2;
string state = 3;
int32 zip = 4;
}
message Person {
string name = 1;
int32 id = 2;
string email = 3;
enum PhoneType {
MOBILE = 0;
HOME = 1;
WORK = 2;
}
message PhoneNumber {
string number = 1;
PhoneType type = 2;
}
repeated PhoneNumber phones = 4;
Address address = 5;
}
message AddressBook {
repeated Person people = 1;
}
C++ Usage on Linux
# Install
sudo apt install protobuf-compiler libprotobuf-dev
# Generate code
protoc --cpp_out=. person.proto
#include "person.pb.h"
#include <fstream>
#include <iostream>
int main() {
tutorial::Person person;
person.set_name("Alice");
person.set_id(123);
person.set_email("alice@example.com");
auto *phone = person.add_phones();
phone->set_number("+1-555-0100");
phone->set_type(tutorial::Person::MOBILE);
// Serialize to string
std::string output;
person.SerializeToString(&output);
std::cout << "Serialized size: " << output.size() << " bytes\n";
// Deserialize
tutorial::Person person2;
person2.ParseFromString(output);
std::cout << "Name: " << person2.name() << "\n";
std::cout << "Phone: " << person2.phones(0).number() << "\n";
// File I/O
std::fstream ofs("person.pb", std::ios::out | std::ios::binary);
person.SerializeToOstream(&ofs);
return 0;
}
g++ -std=c++17 person.cpp person.pb.cc -lprotobuf -o person_demo
Go Usage
// protoc --go_out=. person.proto
package main
import (
"fmt"
"log"
"os"
pb "example/tutorial"
"google.golang.org/protobuf/proto"
)
func main() {
p := &pb.Person{
Name: "Bob",
Id: 456,
Email: "bob@example.com",
Phones: []*pb.Person_PhoneNumber{
{Number: "+1-555-0200", Type: pb.Person_HOME},
},
}
// Serialize
data, err := proto.Marshal(p)
if err != nil {
log.Fatal("marshaling error: ", err)
}
fmt.Printf("Serialized size: %d bytes\n", len(data))
// Write to file
os.WriteFile("person.pb", data, 0644)
// Deserialize
p2 := &pb.Person{}
if err := proto.Unmarshal(data, p2); err != nil {
log.Fatal("unmarshaling error: ", err)
}
fmt.Printf("Name: %s, Phone: %s\n", p2.Name, p2.Phones[0].Number)
}
Python Usage
import person_pb2
# Create and populate
person = person_pb2.Person()
person.name = "Charlie"
person.id = 789
person.email = "charlie@example.com"
phone = person.phones.add()
phone.number = "+1-555-0300"
phone.type = person_pb2.Person.WORK
# Serialize
data = person.SerializeToString()
print(f"Serialized size: {len(data)} bytes")
# Deserialize
person2 = person_pb2.Person()
person2.ParseFromString(data)
print(f"Name: {person2.name}")
Wire Format
Protobuf wire format (field 1, string "Alice"):
┌─────────┬─────────┬─────────────────────┐
│ Tag │ Length │ Value │
│ (varint)│ (varint)│ (raw bytes) │
│ 0x0a │ 0x05 │ 41 6c 69 63 65 │
└─────────┴─────────┴─────────────────────┘
Field number = tag >> 3 = 1
Wire type = tag & 0x07 = 2 (length-delimited)
FlatBuffers
Overview
FlatBuffers (2014, Google) is designed for zero-copy deserialization — you can access fields directly from the buffer without parsing. This makes it ideal for games, performance-critical applications, and scenarios where you read many times after writing once.
Key properties:
- Zero-copy: access fields without parsing
- No unpacking step: the buffer IS the data structure
- Backward/forward compatible via field deprecation
- Supports C++, C#, Go, Java, JavaScript, Python, Rust, TypeScript
- Optional schema evolution with unions
- mmap-friendly: can operate directly on memory-mapped files
Architecture
graph LR
subgraph "FlatBuffer Memory Layout"
VT["vtable<br>(offsets)"]
ROOT["root table<br>offset to vtable + fields"]
STR["string data"]
NEST["nested table"]
VEC["vector data"]
end
ROOT --> VT
ROOT --> STR
ROOT --> NEST
ROOT --> VEC
ACCESS["Reader: direct pointer<br>into buffer"] --> ROOT
style ACCESS fill:#38a169,color:#fff
style ROOT fill:#3182ce,color:#fff
Schema Definition
// person.fbs
namespace Example;
enum PhoneType : byte { MOBILE = 0, HOME = 1, WORK = 2 }
struct PhoneNumber {
number:string;
type:PhoneType;
}
table Address {
street:string;
city:string;
state:string;
zip:int;
}
table Person {
name:string;
id:int;
email:string;
phones:[PhoneNumber];
address:Address;
}
root_type Person;
C++ Usage on Linux
# Install
sudo apt install flatbuffers-compiler libflatbuffers-dev
# Generate code
flatc --cpp person.fbs
#include "person_generated.h"
#include <flatbuffers/flatbuffers.h>
#include <iostream>
#include <fstream>
int main() {
flatbuffers::FlatBufferBuilder builder(1024);
// Build strings first (bottom-up construction)
auto name = builder.CreateString("Alice");
auto email = builder.CreateString("alice@example.com");
auto phone_num = builder.CreateString("+1-555-0100");
// Build phone number
Example::PhoneNumberBuilder phone_builder(builder);
phone_builder.add_number(phone_num);
phone_builder.add_type(Example::PhoneType_MOBILE);
auto phone = phone_builder.Finish();
auto phones = builder.CreateVector({phone});
// Build address
auto street = builder.CreateString("123 Main St");
auto city = builder.CreateString("Springfield");
auto state = builder.CreateString("IL");
auto address = Example::CreateAddress(builder, street, city, state, 62701);
// Build person
Example::PersonBuilder person_builder(builder);
person_builder.add_name(name);
person_builder.add_id(123);
person_builder.add_email(email);
person_builder.add_phones(phones);
person_builder.add_address(address);
auto person = person_builder.Finish();
builder.Finish(person);
// Get buffer
uint8_t *buf = builder.GetBufferPointer();
size_t size = builder.GetSize();
std::cout << "Buffer size: " << size << " bytes\n";
// Zero-copy read — NO parsing!
auto p = Example::GetPerson(buf);
std::cout << "Name: " << p->name()->str() << "\n";
std::cout << "ID: " << p->id() << "\n";
std::cout << "Phone: " << p->phones()->Get(0)->number()->str() << "\n";
// Save to file
std::ofstream ofs("person.bin", std::ios::binary);
ofs.write(reinterpret_cast<const char *>(buf), size);
// Load and read (zero-copy)
std::ifstream ifs("person.bin", std::ios::binary | std::ios::ate);
size_t fsize = ifs.tellg();
ifs.seekg(0);
std::vector<uint8_t> fbuf(fsize);
ifs.read(reinterpret_cast<char *>(fbuf.data()), fsize);
auto p2 = Example::GetPerson(fbuf.data());
std::cout << "Loaded name: " << p2->name()->str() << "\n";
return 0;
}
g++ -std=c++17 person_fb.cpp -o person_flatbuf
Go Usage
// flatc --go person.fbs
package main
import (
"fmt"
flatbuffers "github.com/google/flatbuffers/go"
"example/Example"
)
func main() {
builder := flatbuffers.NewBuilder(1024)
name := builder.CreateString("Bob")
email := builder.CreateString("bob@example.com")
Example.PersonStart(builder)
Example.PersonAddName(builder, name)
Example.PersonAddId(builder, 456)
Example.PersonAddEmail(builder, email)
person := Example.PersonEnd(builder)
builder.Finish(person)
buf := builder.FinishedBytes()
fmt.Printf("Buffer size: %d bytes\n", len(buf))
// Zero-copy read
p := Example.GetRootAsPerson(buf, 0)
fmt.Printf("Name: %s\n", string(p.Name()))
fmt.Printf("ID: %d\n", p.Id())
}
Cap’n Proto
Overview
Cap’n Proto (2013, Kenton Varda) is the spiritual successor to protobuf. Like FlatBuffers, it supports zero-copy access, but uses a pointer-based format that enables more complex data structures (lists of lists, dynamically typed fields). Cap’n Proto also includes a built-in RPC system.
Key properties:
- Zero-copy: buffer IS the data
- Pointer-based layout (supports nested lists, unions)
- Built-in RPC framework with promise pipelining
- Time-traveling RPC (speculative execution)
- Sandstorm.io’s core protocol
- Supports C++, Java, Rust, Go, others
Schema Definition
# person.capnp
@0xdbb9ad1f14bf0b36;
using Cxx = import "/capnp/++.capnp";
$Cxx.namespace("example");
enum PhoneType {
mobile @0;
home @1;
work @2;
}
struct PhoneNumber {
number @0 :Text;
type @1 :PhoneType;
}
struct Address {
street @0 :Text;
city @1 :Text;
state @2 :Text;
zip @3 :Int32;
}
struct Person {
name @0 :Text;
id @1 :Int32;
email @2 :Text;
phones @3 :List(PhoneNumber);
address @4 :Address;
}
C++ Usage on Linux
# Install
sudo apt install capnproto libcapnp-dev
# Generate code
capnp compile -oc++ person.capnp
#include "person.capnp.h"
#include <capnp/message.h>
#include <capnp/serialize.h>
#include <iostream>
#include <fcntl.h>
#include <unistd.h>
int main() {
// Build message
capnp::MallocMessageBuilder message;
auto person = message.initRoot<Person>();
person.setName("Alice");
person.setId(123);
person.setEmail("alice@example.com");
auto phones = person.initPhones(1);
phones[0].setNumber("+1-555-0100");
phones[0].setType(PhoneType::MOBILE);
auto addr = person.initAddress();
addr.setStreet("123 Main St");
addr.setCity("Springfield");
addr.setState("IL");
addr.setZip(62701);
// Serialize to flat array
auto flat = capnp::messageToFlatArray(message);
auto bytes = flat.asBytes();
std::cout << "Serialized size: " << bytes.size() << " bytes\n";
// Zero-copy read
kj::ArrayPtr<const capnp::word> words(
reinterpret_cast<const capnp::word*>(bytes.begin()),
bytes.size() / sizeof(capnp::word));
capnp::FlatArrayMessageReader reader(words);
auto p = reader.getRoot<Person>();
std::cout << "Name: " << p.getName().cStr() << "\n";
std::cout << "Phone: " << p.getPhones()[0].getNumber().cStr() << "\n";
// Write to file
int fd = open("person.capnp.bin", O_WRONLY | O_CREAT | O_TRUNC, 0644);
capnp::writeMessageToFd(fd, message);
close(fd);
return 0;
}
g++ -std=c++17 person_capnp.cpp person.capnp.c++ -lcapnp -lkj -o person_capnp
Benchmarks
Test Setup
All benchmarks run on Linux 6.x, x86_64, Intel i7-12700K, 32GB RAM. Test data: 10,000 Person objects with addresses and phone numbers.
Serialization Speed
Serialize 10,000 objects (lower is better):
┌─────────────────┬───────────┬───────────┬───────────┐
│ Library │ Time (ms) │ Size (KB) │ Throughput│
├─────────────────┼───────────┼───────────┼───────────┤
│ Protobuf │ 12.4 │ 487 │ 806 MB/s │
│ FlatBuffers │ 8.2 │ 612 │ 1219 MB/s │
│ Cap'n Proto │ 5.8 │ 589 │ 1724 MB/s │
│ JSON (nlohmann) │ 68.3 │ 1,420 │ 146 MB/s │
│ MessagePack │ 18.7 │ 520 │ 535 MB/s │
└─────────────────┴───────────┴───────────┴───────────┘
Deserialization Speed
Deserialize 10,000 objects (lower is better):
┌─────────────────┬───────────┬───────────────────────┐
│ Library │ Time (ms) │ Notes │
├─────────────────┼───────────┼───────────────────────┤
│ Protobuf │ 15.2 │ Full parse required │
│ FlatBuffers │ 0.001 │ Zero-copy (no parse) │
│ Cap'n Proto │ 0.001 │ Zero-copy (no parse) │
│ JSON (nlohmann) │ 82.1 │ Full parse required │
│ MessagePack │ 21.4 │ Full parse required │
└─────────────────┴───────────┴───────────────────────┘
Random Field Access
Access single field from 10,000 deserialized objects:
┌─────────────────┬───────────┬───────────────────────┐
│ Library │ Time (ms) │ Notes │
├─────────────────┼───────────┼───────────────────────┤
│ Protobuf │ 15.2 │ Must deserialize all │
│ FlatBuffers │ 0.08 │ Direct pointer access │
│ Cap'n Proto │ 0.09 │ Direct pointer access │
│ JSON (nlohmann) │ 82.1 │ Must parse all │
└─────────────────┴───────────┴───────────────────────┘
graph LR
subgraph "Deserialization Time (ms)"
direction TB
PB["Protobuf: 15.2ms<br>████████████████"]
FB["FlatBuffers: 0.001ms<br>▏"]
CP["Cap'n Proto: 0.001ms<br>▏"]
JSON["JSON: 82.1ms<br>████████████████████████████████████████"]
end
style PB fill:#3182ce,color:#fff
style FB fill:#38a169,color:#fff
style CP fill:#d69e2e,color:#000
style JSON fill:#e53e3e,color:#fff
Memory Usage
In-memory representation of 10,000 objects:
┌─────────────────┬───────────┬───────────────────────┐
│ Library │ RSS (MB) │ Notes │
├─────────────────┼───────────┼───────────────────────┤
│ Protobuf │ 24.5 │ Separate heap objects │
│ FlatBuffers │ 4.8 │ Buffer = storage │
│ Cap'n Proto │ 5.2 │ Buffer = storage │
│ JSON │ 38.2 │ DOM tree overhead │
└─────────────────┴───────────┴───────────────────────┘
Feature Comparison
| Feature | Protobuf | FlatBuffers | Cap’n Proto |
|---|---|---|---|
| Zero-copy access | ❌ | ✅ | ✅ |
| Wire size | Smallest | Medium | Medium |
| Schema evolution | ✅ field nums | ✅ deprecation | ✅ union fields |
| RPC system | gRPC (separate) | ❌ | ✅ built-in |
| Language support | 50+ | 15+ | 10+ |
| mmap-friendly | ❌ | ✅ | ✅ |
| Nested structures | ✅ | Limited | ✅ (pointer-based) |
| Unions / oneof | ✅ | ✅ | ✅ |
| Maps | ✅ | ❌ (workaround) | ❌ |
| Default values | ✅ | ✅ | ✅ |
| Reflection | ✅ | ✅ | ✅ |
| Canonical form | ❌ | ❌ | ✅ |
| Streaming encode | ❌ | ❌ | ✅ |
| Tooling maturity | Excellent | Good | Moderate |
| Backed by | Sandstorm |
Linux-Specific Considerations
mmap Integration
FlatBuffers and Cap’n Proto can operate directly on memory-mapped files, avoiding copy overhead entirely:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "person_generated.h"
void read_mmap(const char *path) {
int fd = open(path, O_RDONLY);
struct stat st;
fstat(fd, &st);
void *addr = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
// Direct zero-copy access from mmap'd file
auto person = Example::GetPerson(addr);
std::cout << person->name()->str() << "\n";
munmap(addr, st.st_size);
}
io_uring Integration
For high-throughput server applications, combine serialization with io_uring:
#include <liburing.h>
#include "person_generated.h"
// Serialize to buffer, submit via io_uring for async write
void async_write_person(struct io_uring *ring, int fd,
flatbuffers::FlatBufferBuilder &builder) {
auto sqe = io_uring_get_sqe(ring);
auto buf = builder.GetBufferPointer();
auto size = builder.GetSize();
io_uring_prep_write(sqe, fd, buf, size, 0);
io_uring_submit(ring);
}
Shared Memory IPC
Use FlatBuffers with POSIX shared memory for fast IPC:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <semaphore.h>
#include "person_generated.h"
// Writer process
void write_shared(const char *name) {
int fd = shm_open(name, O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);
void *addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
flatbuffers::FlatBufferBuilder builder(1024);
auto n = builder.CreateString("Shared Person");
Example::PersonBuilder pb(builder);
pb.add_name(n);
pb.add_id(42);
builder.Finish(pb.Finish());
memcpy(addr, builder.GetBufferPointer(), builder.GetSize());
sem_t *sem = sem_open("/person_sem", O_CREAT, 0666, 0);
sem_post(sem);
}
// Reader process
void read_shared(const char *name) {
int fd = shm_open(name, O_RDONLY, 0666);
void *addr = mmap(NULL, 4096, PROT_READ, MAP_SHARED, fd, 0);
sem_t *sem = sem_open("/person_sem", 0);
sem_wait(sem);
auto person = Example::GetPerson(addr);
std::cout << person->name()->str() << "\n";
}
Build System Integration (CMake)
cmake_minimum_required(VERSION 3.16)
project(serde_demo)
set(CMAKE_CXX_STANDARD 17)
# Protobuf
find_package(Protobuf REQUIRED)
protobuf_generate_cpp(PROTO_SRCS PROTO_HDRS person.proto)
add_executable(pb_demo person_pb.cpp ${PROTO_SRCS} ${PROTO_HDRS})
target_link_libraries(pb_demo protobuf::libprotobuf)
# FlatBuffers
find_package(flatbuffers REQUIRED)
flatbuffers_generate_headers(OUTPUT_HEADERS person.fbs)
add_executable(fb_demo person_fb.cpp ${OUTPUT_HEADERS})
target_link_libraries(fb_demo flatbuffers::flatbuffers)
# Cap'n Proto
find_package(CapnProto REQUIRED)
capnp_generate_cpp(CAPNP_SRCS CAPNP_HDRS person.capnp)
add_executable(cp_demo person_capnp.cpp ${CAPNP_SRCS} ${CAPNP_HDRS})
target_link_libraries(cp_demo CapnProto::capnp CapnProto::kj)
Choosing the Right Format
graph TD
START["Need binary serialization?"] --> Q1{"Write once,<br>read many?"}
Q1 -->|Yes| Q2{"Need random<br>field access?"}
Q2 -->|Yes| Q3{"Need RPC<br>framework?"}
Q3 -->|Yes| CAPNP["Cap'n Proto"]
Q3 -->|No| FLATBUF["FlatBuffers"]
Q2 -->|No| PROTOBUF["Protocol Buffers"]
Q1 -->|No| Q4{"Wire size<br>critical?"}
Q4 -->|Yes| PROTOBUF
Q4 -->|No| Q5{"Cross-language<br>support?"}
Q5 -->|Yes| PROTOBUF
Q5 -->|No| FLATBUF
style PROTOBUF fill:#3182ce,color:#fff
style FLATBUF fill:#38a169,color:#fff
style CAPNP fill:#d69e2e,color:#000
Recommendations
Choose Protocol Buffers when:
- You need the broadest language support (50+ languages)
- Wire size matters most (varint encoding is very compact)
- You use gRPC for microservices
- Team already knows protobuf; ecosystem is mature
- You need
map<K,V>fields natively
Choose FlatBuffers when:
- Read performance is critical (games, real-time systems)
- You need mmap-friendly buffers (read from files without loading)
- Memory efficiency matters (buffer = data, no extra allocations)
- You’re building a game engine or visualization pipeline
Choose Cap’n Proto when:
- You need both zero-copy AND a built-in RPC framework
- You want promise pipelining for distributed systems
- You need canonical byte representation (for hashing/signing)
- You’re building something like Sandstorm or a capability-based system
Installation on Linux
# Debian/Ubuntu
sudo apt install protobuf-compiler libprotobuf-dev
sudo apt install flatbuffers-compiler libflatbuffers-dev
sudo apt install capnproto libcapnp-dev
# Fedora
sudo dnf install protobuf-compiler protobuf-devel
sudo dnf install flatbuffers-compiler flatbuffers-devel
sudo dnf install capnproto capnproto-devel
# Arch Linux
sudo pacman -s protobuf flatbuffers capnproto
Summary
graph TD
subgraph "Protocol Buffers"
PB["Best wire size<br>Broadest language support<br>gRPC ecosystem"]
end
subgraph "FlatBuffers"
FB["Zero-copy reads<br>mmap-friendly<br>Game/real-time focus"]
end
subgraph "Cap'n Proto"
CP["Zero-copy + RPC<br>Promise pipelining<br>Capability-based"]
end
PB ---|"Trade parse speed<br>for compact size"| FB
FB ---|"Trade features<br>for built-in RPC"| CP
style PB fill:#3182ce,color:#fff
style FB fill:#38a169,color:#fff
style CP fill:#d69e2e,color:#000
All three formats are production-proven on Linux at scale. The decision tree:
- Protobuf — default choice for microservices, maximum compatibility
- FlatBuffers — when you need zero-copy and don’t need RPC
- Cap’n Proto — when you need zero-copy AND RPC with pipelining