Library / SDK
eyalz800/zpp_bits avatar
eyalz800/zpp_bits

zpp::bits: binary serialization and a small RPC layer in one C++20 header

A lightweight C++20 serialization and RPC library

1,011 stars75 forksC++MIT

At a glance

What is it?
zpp::bits is a single-header C++20 serialization library that is the successor to zpp::serializer, with archive-based input and output, three error handling models, and lightweight RPC. This looks at how the API is shaped and what the C++20 requirement costs you.
Who is it for?
zpp::bits fits a C++ project that serialises its own structs to binary for a network protocol, a cache, or a file, and that wants a header-only dependency with no build system integration. It does not fit a codebase that must stay on C++17 or earlier, because the library breaks compatibility with anything lower than C++20 and leaves the predecessor library in place for that reason.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 74 days ago.
What is it written in?
Mainly C++, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 21, 2026, and from our analysis. They are not legal advice.

Editorial analysis

One header, and it is a rewrite rather than an increment

zpp::bits is described as a modern C++20 binary serialization and RPC library, with just one header file. The repository is accordingly small: a `zpp_bits.h` at the root, a `test/` directory, a `LICENSE`, an Azure Pipelines configuration and GitHub workflows.

The library is the successor to zpp::serializer, and the documentation is explicit that it tries to be simpler for use while keeping more or less a similar API to its predecessor. The list of differences is worth reading because it is the design brief.

It is simpler. There are performance improvements. Almost everything is `constexpr`. It is more flexible about the serialization of the size of variable length types, with the option to opt out from serializing size. It opts in to zpp::throwing if that header is found. It is more friendly towards freestanding environments, meaning no exception runtime support. It breaks compatibility with anything lower than C++20, which is the stated reason the original library is left intact. The utility classes and namespaces are better named, with `zpp::bits` easier to type than `zpp::serializer`. Polymorphism is modernised, based on variant and flexible serialization ids, rather than the global polymorphic types with a fixed 8 bytes of sha1 serialization id that the predecessor used.

That last item is the most substantive change, and it is the one that matters most if you are migrating.

The licence is MIT, the last push to the repository was on 2026-08-09, and the recent releases are v4.7.4, v4.7.5 and v4.7.6, all published on that same day within about half an hour of each other.

Your types have to be aggregates, until C++26 lifts that

Enabling serialization is meant to be transparent, requiring no additional lines of code for many types. The condition is that the type must be an aggregate type with non-array members. Post C++26, the feature (P1061) Structured bindings can introduce a pack lifts that requirement.

That is a precise limitation and it is worth understanding what it means in practice. A plain struct with a string and an int is an aggregate and works immediately:

cpp
struct person
{
    std::string name;
    int age{};
};

A class with private members, virtual functions, a user-declared constructor, or a base class is not an aggregate, and the library does not make it one. The documentation has dedicated sections for serializing non-aggregates and for serializing private classes, which is the tell: those types need extra work, and the work is on you.

The C++26 note is more than a curiosity for library authors. Until that standard is available in your toolchain, the aggregate requirement stands, which means the set of types you can serialize without writing something extra is narrower than it looks on paper.

This is a real difference from the predecessor approach and from schema-based serializers, where a class hierarchy is a normal thing to describe.

One utility function gives you the data and both archives

The API shape is unusual in a friendly way. The `data_in_out` utility function creates a vector of bytes, the input and output archives, and returns them, so the whole thing decomposes in one line with structured binding:

cpp
auto [data, in, out] = zpp::bits::data_in_out();

// Serialize a few people:
out(person{"Person1", 25}, person{"Person2", 35});

// Define our people.
person p1, p2;

// We can now deserialize them either one by one `in(p1)` `in(p2)`, or together, here
// we chose to do it together in one line:
in(p1, p2);

The archives are the interesting part: the output archive writes into the byte vector, the input archive reads from it, and each call accepts a variadic list of objects, which is why two people can be written in one call and read back in one call.

The documentation notes that this example almost works, because you are being warned that you are discarding the return value, and that error checking is the next topic. That is the honest framing: the fast path is one line, and the checked path costs a return value.

Around this core the documentation lists a broad set of capabilities, each with its own page: explicit serialization, archive creation, constexpr serialization, position control, standard library types serialization, serialization as bytes, variant types and version control, literal operators, apply to functions, remote procedure call, byte order customization, deserializing a view of const bytes, pointers as optionals, reflection, additional archive controls, variable length integers, protobuf, and advanced controls.

Three error models, chosen when you build

Error handling is where a serialization library earns its keep, because a truncated buffer is the normal failure mode when reading untrusted input, and zpp::bits offers three ways to handle it.

The first is return value based, described as the most explicit, and the result is implicitly convertible to `std::errc`:

cpp
auto [data, in, out] = zpp::bits::data_in_out();

auto result = out(person{"Person1", 25}, person{"Person2", 35});
if (failure(result)) {
    // `result` is implicitly convertible to `std::errc`.
    // handle the error or return/throw exception.
}

person p1, p2;

result = in(p1, p2);
if (failure(result)) {
    // handle the error or return/throw exception.
}

The second is exceptions, using `.or_throw()`, which the documentation tells you to read as succeed or throw. The third is zpp::throwing, a separate opt-in library, where error checking becomes two `co_await`s inside a coroutine handled by `zpp::try_catch`.

All three models use the same error codes internally, so the choice affects how your code reads rather than what it can detect. The documented codes include `std::errc::result_out_of_range` for attempting to write or read from a too short buffer, `std::errc::no_buffer_space` for growing the buffer beyond the allocation limits or overflowing, and `std::errc::value_too_large` for varint encoding beyond the representation limits.

Since the throwing model is opt-in and only used if the header is found, that is a build configuration decision, not a runtime one. A freestanding build without an exception runtime has to pick the return value style, and the library is deliberately friendly to that environment.

RPC is present, and deliberately lightweight

The library advertises lightweight remote procedure call capabilities as one of its three motivations, alongside seamless binary serialization and being the fastest possible.

The word to hold onto is lightweight. The documentation lists RPC as one of the many feature sections, alongside the serialization topics, rather than as a separate distributed systems story. There is no mention of a registry, a service discovery mechanism, a network transport, or a wire protocol, and the earlier sections say nothing about transports.

The reasonable reading is that this is an RPC mechanism built on the same archive primitives, where you define how a call is framed and how a response is framed, rather than a framework that decides those things for you. For a project that already has a transport and wants a compact call format, that is exactly what you want. For a project that wants to stand up a service with discovery, health checking and load balancing, it is not a distributed systems framework and does not pretend to be.

The performance argument is the other half of the motivation, and the documentation points at a benchmark section for the numbers, with a `benchmarks` reference also present in the table of contents. The repository root itself is minimal, with a `test/` directory and a `zpp_bits.h` file, and the CI is set up on both GitHub Actions and Azure Pipelines.

Where zpp::bits is the wrong tool

The C++20 requirement is the first and bluntest limit. The library breaks compatibility with anything lower than C++20, and the documentation gives the reason the predecessor is left intact rather than updated. A codebase that cannot move to C++20 has to stay on zpp::serializer, and the two do not share a compatibility story, since the polymorphism model changed from global types with a fixed 8 byte sha1 id to variant based types with flexible serialization ids. That is a data format change, not just an API change, so migrating means invalidating whatever you have already written to disk.

The second limit is the aggregate requirement, and until C++26 structured bindings are available it is a real constraint. Non-aggregate types and private classes need the explicit serialization paths, which is extra code per type and a decision to make for every class in your model that does not happen to be a plain struct.

The third limit is that this is not a schema-first library. You define the layout. The protobuf support in the feature list is an interoperability path rather than a schema-driven default, and if your organisation has a rule that wire formats come from a schema registry, that rule is not satisfied by default here.

The fourth is error model rigidity. A freestanding build without an exception runtime has to pick the return value style.

And the release pattern deserves a note. v4.7.4, v4.7.5 and v4.7.6 were all published on 2026-08-09 within about half an hour, and the last push to the repository carries the same date. That looks like automated tagging rather than a hand-curated release process, so a project depending on it should pin an exact version rather than track the tag series.

Editorial conclusion

zpp::bits fits a C++ project that serialises its own structs to binary for a network protocol, a cache, or a file, and that wants a header-only dependency with no build system integration. It does not fit a codebase that must stay on C++17 or earlier, because the library breaks compatibility with anything lower than C++20 and leaves the predecessor library in place for that reason. It is also the wrong choice if you need a proven wire format: the design is deliberately yours to define, and the protobuf support is an interoperability path rather than the default. Before you adopt it, check that your types are aggregates, since the requirement is only lifted by C++26 structured bindings, and decide which error model you want.

Frequently asked questions

How do I serialize a struct with zpp::bits?

Create the data and the archives with `auto [data, in, out] = zpp::bits::data_in_out();`, then write with `out(person{"Person1", 25})` and read back with `in(p1)`. The type must be an aggregate with non-array members, a requirement that is only lifted by C++26 structured bindings.

How does zpp::bits report errors?

Three ways: a return value that is implicitly convertible to `std::errc`, exceptions via `.or_throw()`, and the opt-in zpp::throwing coroutine model using `co_await`. The documented codes include `std::errc::result_out_of_range` for a too short buffer, `std::errc::no_buffer_space` for buffer growth beyond limits, and `std::errc::value_too_large` for varint encoding limits.

What is the difference between zpp::bits and zpp::serializer?

zpp::bits is the successor, simpler and with performance improvements, almost everything constexpr, more flexible varint size handling with an opt out, opt-in zpp::throwing support, better freestanding friendliness, and a modernised polymorphism model based on variant and flexible serialization ids. It requires C++20, which is why the original library is left intact.

Does zpp::bits include a network transport for its RPC support?

The documentation describes the RPC capability as lightweight and lists it among the feature sections, without describing a transport, a registry or a service discovery mechanism. The library is about the framing of calls and responses on top of its archives, not about running a distributed service.

What licence is zpp::bits released under?

MIT, with the licence file at the repository root. The library is header only, distributed as a single `zpp_bits.h` file, so integrating it means copying one header into your build rather than adding a package dependency.

Official sources

  1. eyalz800/zpp_bits on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/eyalz800-zpp-bits.svg)](https://hysenlabs.com/projects/eyalz800-zpp-bits)

Was this useful?