Serialization in C++
C++ serialization patterns — manual binary, nlohmann/json, Protocol Buffers, MessagePack, std::from_chars for fast parsing, and versioning strategies.
TL;DR
C++ has no built-in serialization. Choose based on: human-readable (JSON), schema-enforced (Protobuf/Flatbuffers), or fast binary (manual/MessagePack). Always version your binary formats.
Manual Binary Serialization
Zero-copy, maximum control:
#include <cstring>
#include <vector>
// Simple buffer writer/reader
class BinaryWriter {
std::vector<std::byte> buf_;
public:
template<typename T>
void write(const T& v) {
static_assert(std::is_trivially_copyable_v<T>);
auto* ptr = reinterpret_cast<const std::byte*>(&v);
buf_.insert(buf_.end(), ptr, ptr + sizeof(T));
}
void write_string(std::string_view s) {
write(static_cast<uint32_t>(s.size()));
auto* ptr = reinterpret_cast<const std::byte*>(s.data());
buf_.insert(buf_.end(), ptr, ptr + s.size());
}
std::span<const std::byte> data() const { return buf_; }
};
class BinaryReader {
const std::byte* ptr_;
const std::byte* end_;
public:
BinaryReader(std::span<const std::byte> data)
: ptr_{data.data()}, end_{data.data() + data.size()} {}
template<typename T>
T read() {
if (ptr_ + sizeof(T) > end_) throw std::out_of_range("read past end");
T v;
std::memcpy(&v, ptr_, sizeof(T));
ptr_ += sizeof(T);
return v;
}
std::string read_string() {
auto len = read<uint32_t>();
if (ptr_ + len > end_) throw std::out_of_range("string too long");
std::string s{reinterpret_cast<const char*>(ptr_), len};
ptr_ += len;
return s;
}
};
struct Packet {
uint32_t id;
float value;
std::string name;
void serialize(BinaryWriter& w) const {
w.write(id); w.write(value); w.write_string(name);
}
static Packet deserialize(BinaryReader& r) {
return {r.read<uint32_t>(), r.read<float>(), r.read_string()};
}
};JSON with nlohmann/json
The most popular C++ JSON library:
#include <nlohmann/json.hpp>
using json = nlohmann::json;
// Build JSON
json j;
j["name"] = "Alice";
j["age"] = 30;
j["active"] = true;
j["scores"] = {95, 88, 92};
j["address"] = {{"city", "New York"}, {"zip", "10001"}};
std::string s = j.dump(2); // pretty-print with indent=2
// Parse JSON
json parsed = json::parse(R"({"name": "Bob", "age": 25})");
std::string name = parsed["name"];
int age = parsed.at("age"); // throws if missing
// Structured bindings
auto [name2, age2] = parsed.items();
// Custom serialization via to_json/from_json
struct Person { std::string name; int age; };
void to_json(json& j, const Person& p) {
j = json{{"name", p.name}, {"age", p.age}};
}
void from_json(const json& j, Person& p) {
j.at("name").get_to(p.name);
j.at("age").get_to(p.age);
}
Person alice{"Alice", 30};
json jalice = alice; // to_json called automatically
Person bob = json::parse(R"({"name":"Bob","age":25})").get<Person>();Protocol Buffers (Protobuf)
Schema-enforced, strongly typed, efficient:
// person.proto
syntax = "proto3";
message Person {
string name = 1;
int32 age = 2;
repeated string emails = 3;
}// C++ generated code usage
#include "person.pb.h"
Person p;
p.set_name("Alice");
p.set_age(30);
p.add_emails("alice@example.com");
// Serialize to string
std::string bytes;
p.SerializeToString(&bytes);
// Deserialize
Person p2;
p2.ParseFromString(bytes);
std::println("{}", p2.name()); // AliceMessagePack (Fast Binary JSON)
Binary, schema-less, compact:
#include <msgpack.hpp>
struct Config {
std::string host;
int port;
MSGPACK_DEFINE(host, port) // macro generates serialize/deserialize
};
// Serialize
Config cfg{"localhost", 8080};
msgpack::sbuffer sbuf;
msgpack::pack(sbuf, cfg);
// Deserialize
auto handle = msgpack::unpack(sbuf.data(), sbuf.size());
auto obj = handle.get();
Config cfg2;
obj.convert(cfg2);Fast Number Parsing with from_chars
For custom text protocols where speed matters:
#include <charconv>
// Parse a CSV line: "1.5,2.7,3.9\n"
std::vector<double> parse_doubles(std::string_view line) {
std::vector<double> vals;
auto p = line.data();
auto end = line.data() + line.size();
while (p < end) {
double v;
auto [next, ec] = std::from_chars(p, end, v);
if (ec != std::errc{}) break;
vals.push_back(v);
p = next;
if (p < end && (*p == ',' || *p == '\n')) ++p;
}
return vals;
}Versioned Binary Format
struct FileHeader {
uint32_t magic; // 0x43504200 "CPB\0"
uint16_t version; // format version
uint16_t flags;
uint64_t data_size;
};
static_assert(sizeof(FileHeader) == 16);
class VersionedSerializer {
static constexpr uint32_t kMagic = 0x43504200;
static constexpr uint16_t kVersion = 2;
public:
void write(std::ostream& out, const MyData& data) {
FileHeader hdr{kMagic, kVersion, 0, 0};
// serialize data into buffer...
std::vector<std::byte> payload = serialize_payload(data);
hdr.data_size = payload.size();
out.write(reinterpret_cast<const char*>(&hdr), sizeof(hdr));
out.write(reinterpret_cast<const char*>(payload.data()), payload.size());
}
MyData read(std::istream& in) {
FileHeader hdr;
in.read(reinterpret_cast<char*>(&hdr), sizeof(hdr));
if (hdr.magic != kMagic) throw std::runtime_error("bad magic");
if (hdr.version > kVersion) throw std::runtime_error("version too new");
std::vector<std::byte> payload(hdr.data_size);
in.read(reinterpret_cast<char*>(payload.data()), hdr.data_size);
if (hdr.version == 1) return deserialize_v1(payload);
return deserialize_v2(payload);
}
};Library Comparison
| Library | Format | Schema | Speed | Use when |
|---|---|---|---|---|
nlohmann/json | JSON | No | Medium | Human-readable, debugging |
simdjson | JSON | No | Very fast | High-throughput JSON parsing |
| Protobuf | Binary | Required | Fast | Cross-language, versioned APIs |
| Flatbuffers | Binary | Required | Very fast | Zero-copy access |
| MessagePack | Binary | No | Fast | Binary JSON without schema |
| Manual binary | Custom | In code | Maximum | Embedded, protocols |
cereal | JSON/Binary/XML | In code | Good | C++ only, easy integration |
std::from_chars and std::to_chars — Fast Number I/O
std::from_chars and std::to_chars (C++17) are the fastest standard-library options for number-to-text conversion: no locale, no heap allocation, no exceptions. They are ideal for custom text protocols, CSV ingestion, and any hot path where sscanf/std::stoi/std::to_string show up in profiles.
#include <charconv>
#include <string_view>
#include <array>
// Integer round-trip
int value = 42;
std::array<char, 32> buf;
auto [ptr, ec] = std::to_chars(buf.data(), buf.data() + buf.size(), value);
// ec == std::errc{} on success; ptr points one past the last digit
std::string_view as_text{buf.data(), ptr}; // "42" — no allocation
// Parse back
int parsed;
auto [end, ec2] = std::from_chars(as_text.data(),
as_text.data() + as_text.size(), parsed);
// ec2 == std::errc{} && parsed == 42
// Floating-point (C++17 guarantees round-trip for doubles)
double d = 3.14159265358979;
auto [fptr, fec] = std::to_chars(buf.data(), buf.data() + buf.size(),
d, std::chars_format::shortest);
// "3.14159265358979" — shortest decimal that round-trips exactly
double d2;
std::from_chars(buf.data(), fptr, d2); // d2 == d exactlyfrom_chars is 3-10x faster than strtod for floating-point on typical benchmarks because it avoids global locale locking. Prefer it over sscanf, atof, and stream operators whenever throughput matters.
Versioning and Schema Evolution
Binary formats outlive their first release — fields get added, renamed, or removed, and old readers must still work with new data. The two main approaches are field-ID tagging (Protobuf style) and explicit version dispatch.
// Field-ID tagging: each field is written as (id, size, data)
// Readers skip unknown IDs, so old code silently ignores new fields.
enum class FieldId : uint16_t {
Name = 1,
Age = 2,
Email = 3, // added in v2 — v1 readers skip it safely
};
void write_tagged_field(BinaryWriter& w, FieldId id, std::string_view value) {
w.write(static_cast<uint16_t>(id));
w.write(static_cast<uint32_t>(value.size()));
w.write_bytes(value);
}
// Reader loop
while (reader.bytes_remaining() > 0) {
auto id = reader.read<uint16_t>();
auto size = reader.read<uint32_t>();
switch (static_cast<FieldId>(id)) {
case FieldId::Name: name = reader.read_string_n(size); break;
case FieldId::Age: age = reader.read<int32_t>(); break;
default: reader.skip(size); break; // forward compatibility
}
}| Strategy | Forward compat | Backward compat | Overhead |
|---|---|---|---|
| Field-ID tagging | Yes (skip unknown) | Yes (absent = default) | ~4 bytes/field |
| Explicit version dispatch | Manual | Manual | None |
Protobuf proto3 | Yes | Yes | Built in |
| JSON (named keys) | Yes | Yes | Verbose |
| Fixed struct layout | No | No | Zero |
Flatbuffers and Cap'n Proto — Zero-Copy Access
Flatbuffers and Cap'n Proto encode data so it can be read directly from the wire buffer without a decode step — the serialized bytes are the in-memory representation. This eliminates deserialization time at the cost of slightly more complex accessors.
// FlatBuffers generated accessor — no decode, just pointer arithmetic
#include "person_generated.h" // generated by flatc
// Serialize
flatbuffers::FlatBufferBuilder builder{1024};
auto name_off = builder.CreateString("Alice");
auto person = CreatePerson(builder, name_off, /*age=*/30);
builder.Finish(person);
uint8_t* buf = builder.GetBufferPointer();
size_t size = builder.GetSize();
// Zero-copy read — no allocation, no copy
auto* p = GetPerson(buf);
std::string_view name{p->name()->c_str(), p->name()->size()}; // points into buf
int age = p->age(); // reads from buf// Cap'n Proto — schema-driven, in-place access
#include "person.capnp.h"
#include <capnp/message.h>
#include <capnp/serialize-packed.h>
capnp::MallocMessageBuilder msg;
auto person = msg.initRoot<Person>();
person.setName("Alice");
person.setAge(30);
// Serialize to fd
capnp::writePackedMessageToFd(fd, msg);
// Read back — zero copy from mmap'd file
capnp::PackedFdMessageReader reader{fd};
auto p2 = reader.getRoot<Person>();
std::cout << p2.getName().cStr() << '\n';The trade-off: zero-copy readers are faster for large messages with selective field access, but slower for small messages where full decode into a native struct is comparable in cost.
Custom Binary Protocol Design
A well-designed binary protocol separates a fixed-size header (magic, version, payload length, checksum) from a variable-length payload. This lets readers validate and frame messages without parsing the payload, which is critical for streaming transports.
#pragma pack(push, 1)
struct MsgHeader {
uint32_t magic; // 0x42435050 — "BCPP"
uint8_t version; // protocol version
uint8_t type; // message type enum
uint16_t flags; // reserved
uint32_t length; // payload bytes following this header
uint32_t checksum; // CRC-32 of payload
};
#pragma pack(pop)
static_assert(sizeof(MsgHeader) == 16);
// Endianness — always write as little-endian on the wire
#include <bit>
uint32_t to_le(uint32_t v) {
if constexpr (std::endian::native == std::endian::little) return v;
return __builtin_bswap32(v); // or use std::byteswap (C++23)
}
// Framing a message for a socket
void send_message(int sock, uint8_t type, std::span<const std::byte> payload) {
MsgHeader hdr{};
hdr.magic = to_le(0x42435050);
hdr.version = 1;
hdr.type = type;
hdr.length = to_le(static_cast<uint32_t>(payload.size()));
hdr.checksum = to_le(crc32(payload));
send(sock, &hdr, sizeof(hdr), 0);
send(sock, payload.data(), payload.size(), 0);
}Streaming Serialization
When datasets are larger than available RAM, streaming serialization writes or reads one record at a time without loading the entire payload into memory. This pattern is also necessary for network sockets where data arrives incrementally.
// Streaming writer: flush records one at a time
class StreamingBinaryWriter {
std::ostream& out_;
uint64_t record_count_ = 0;
public:
explicit StreamingBinaryWriter(std::ostream& out) : out_{out} {
// Write placeholder header; seek back to fill count at close
StreamHeader hdr{kMagic, kVersion, /*count=*/0};
out_.write(reinterpret_cast<const char*>(&hdr), sizeof(hdr));
}
void write_record(const Record& r) {
uint32_t len = serialized_size(r);
out_.write(reinterpret_cast<const char*>(&len), 4);
serialize_into(out_, r);
++record_count_;
}
void close() {
// Patch record count in header
out_.seekp(offsetof(StreamHeader, count));
out_.write(reinterpret_cast<const char*>(&record_count_), 8);
}
};
// Streaming reader — process one record, discard, repeat
class StreamingBinaryReader {
std::istream& in_;
public:
explicit StreamingBinaryReader(std::istream& in) : in_{in} {
StreamHeader hdr;
in_.read(reinterpret_cast<char*>(&hdr), sizeof(hdr));
validate_header(hdr);
}
std::optional<Record> next() {
uint32_t len = 0;
in_.read(reinterpret_cast<char*>(&len), 4);
if (in_.eof()) return std::nullopt;
std::vector<std::byte> buf(len);
in_.read(reinterpret_cast<char*>(buf.data()), len);
return deserialize_record(buf);
}
};
// Usage — constant memory regardless of file size
StreamingBinaryReader rdr{file};
while (auto rec = rdr.next())
process(*rec);Reflection-Based Serialization
Reflection-based serialization eliminates per-field boilerplate by iterating struct members programmatically. Boost.PFR (C++17) provides non-intrusive aggregate reflection today; C++26 static reflection will offer a standardised spelling.
// Boost.PFR — serialize any aggregate without macros
#include <boost/pfr.hpp>
#include <nlohmann/json.hpp>
template<typename T>
nlohmann::json to_json_pfr(const T& obj) {
nlohmann::json j = nlohmann::json::object();
boost::pfr::for_each_field(obj, [&](const auto& field, std::size_t idx) {
// PFR gives field values but not names in C++17;
// use index as key or pair with a names tuple
j[std::to_string(idx)] = field;
});
return j;
}
struct Point { float x; float y; float z; };
Point p{1.0f, 2.0f, 3.0f};
auto j = to_json_pfr(p); // {"0":1.0,"1":2.0,"2":3.0}
// With Boost.PFR + a names helper (C++20 NTTP trick):
// j = {"x":1.0,"y":2.0,"z":3.0}C++26 preview (P2996) will allow:
// C++26 static reflection — not yet standard, illustrative only
template<typename T>
void serialize(BinaryWriter& w, const T& obj) {
[:expand(std::meta::nonstatic_data_members_of(^T)):] >> [&]<auto member> {
w.write(obj.[:member:]);
};
}Libraries that work today: Boost.PFR, glaze, reflect-cpp, and magic_get.
Performance Comparison
| Format | Encode speed | Decode speed | Wire size | Zero-copy | Schema |
|---|---|---|---|---|---|
| JSON (nlohmann) | Slow | Slow | Large | No | No |
| JSON (simdjson) | Slow encode | Very fast | Large | Partial | No |
| Protobuf | Fast | Fast | Small | No | Required |
| Flatbuffers | Fast | Zero cost | Small | Yes | Required |
| Cap'n Proto | Fast | Zero cost | Small | Yes | Required |
| MessagePack | Fast | Fast | Medium | No | No |
| Manual binary | Maximum | Maximum | Minimum | Optional | In code |
std::from_chars | — | Maximum | N/A (text) | No | No |
Rough rule of thumb: for latency-critical paths use Flatbuffers or Cap'n Proto; for interoperability and debugging use Protobuf or JSON; for embedded or custom wire protocols use manual binary with std::from_chars/std::to_chars.
See Also
- Manual Binary Serialization Cheatsheet —
std::ostream,std::istream,read/writepatterns - Type Traits Reference —
std::is_trivially_copyable_v,std::endian - Error Handling —
std::expectedvs exceptions for parse errors - Protobuf Domain — integrating
protoccode generation into CMake