Skip to content

C: nanopb vs protobuf-c

Problem

Both protobuf-c and nanopb claim “Protobuf for C,” but they optimize for different worlds: general services with heap messages versus embedded systems with static budgets. Treating them as interchangeable APIs produces the wrong memory model, the wrong failure modes, and unfair suite comparisons.

Short answer

Use protobuf-c when you want classic generated structs, descriptor-driven pack/unpack, and heap-friendly services (protobuf-c path). Use nanopb when RAM/flash are tight, you can declare maximum sizes in options, and you accept stream/callback-oriented encode/decode instead of free-form heap trees. Wire format remains Protobuf binary (wire format); engineering—allocation, codegen, APIs—diverges.

Suite pins (this monorepo): protobuf-c v1.5.0; nanopb 0.4.9 (c/third_party/VERSIONS.md, registration in ser_nanopb.c).

Prerequisites

Mental model

  Same wire (tags, varints, LEN fields)
           │
     ┌─────┴──────┐
     ▼            ▼
 protobuf-c      nanopb
 descriptor      static layout /
 heap unpack     streams + max sizes

Design axes (comparison)

Axis protobuf-c nanopb
Primary goal General C interoperability Embedded / constrained
Schema at runtime ProtobufCMessageDescriptor tables Field lists / generated bind info; options in .proto or .options
Allocation unpack typically heap; free with *_free_unpacked Prefer static structs; optional dynamic with care
Size limits Practical heap limits Max count/size often required for repeated/string/bytes
API shape Fill struct → get_packed_size / pack; unpack pb_ostream_t / pb_istream_t; pb_encode / pb_decode
Callbacks Less central Common for large/unknown-length fields
Submessages Pointers + recursive unpack Nested structs or callbacks depending on options
Unknown fields Often retained for round-trip Policy/options; not “always keep” like desktop stacks
Typical failure OOM / leak if free skipped Encode/decode fail if data exceeds static max
Suite registration protobuf-c nanopb (separate name—compare within C + schema family)

Ownership contrast

protobuf-c
  struct (stack/heap)
       │
       ▼  get_packed_size + pack
  caller buffer (you allocated)
       │
       ▼  unpack
  heap message  ──►  free_unpacked()

nanopb
  static struct (max sizes baked in)
       │
       ▼  pb_encode → pb_ostream_t
  static buffer (pre-sized from worst case)
       │
       ▼  pb_decode → pb_istream_t
  static struct  ──►  usually nothing to free

Minimal nanopb sketch

Illustrative only—field names and generated symbols depend on your .proto / .options and nanopb version (upstream docs).

/* After nanopb codegen: MiniUser has e.g. name[32], tags_count, tags[8] */
MiniUser user = MiniUser_init_zero;
user.id = 1;
strncpy(user.name, "Ada", sizeof(user.name) - 1);
user.has_name = true;   /* if your options use has_ flags */

uint8_t buffer[64];
pb_ostream_t ostream = pb_ostream_from_buffer(buffer, sizeof(buffer));
if (!pb_encode(&ostream, MiniUser_fields, &user)) {
    /* buffer full or encode error */
}

MiniUser out = MiniUser_init_zero;
pb_istream_t istream = pb_istream_from_buffer(buffer, ostream.bytes_written);
if (!pb_decode(&istream, MiniUser_fields, &out)) {
    /* truncated, bad wire, or exceeds max sizes */
}

When valid Protobuf still fails nanopb

Suppose name is generated as a 8-byte array (max_size:8 including null in some setups) and the peer sends a longer string that is valid length-delimited Protobuf:

  1. protobuf-c unpack → heap string of full length (until you OOM).
  2. nanopb pb_decodefalse / error: value does not fit the static contract.

That is intentional for embedded safety, not a wire-format bug. The same applies to repeated counts above max_count.

Step-by-step: how nanopb thinks about encode

(Logical model of nanopb; always check your nanopb version docs.)

N1 — Describe fields with size budgets

Codegen (from .proto + optional .options) produces a C struct where:

  • Scalars are plain fields.
  • Strings/bytes often are fixed arrays or pointer+callback with a maximum length.
  • Repeated fields have a max count (or callback to stream elements).

If the logical Protobuf message can be unbounded, nanopb forces you to cap it at design time—the opposite of “malloc until it fits.”

N2 — Output stream

pb_ostream_t = buffer stream | callback stream
pb_encode(&stream, FieldList, &struct)

Encode walks the field list:

  1. For each present field, emit tag (same wire key rule).
  2. Emit payload (varint, fixed, length-delimited).
  3. Nested messages encode into the stream (length-delimited).
  4. On buffer full or max exceeded → false / error (no silent realloc by default).

N3 — No separate “get_packed_size then malloc” requirement

You can size a static buffer from worst-case maxima, or use a sizing stream. The mental model is stream into a budget, not “measure unlimited tree then allocate.”

Step-by-step: how nanopb thinks about decode

N4 — Input stream

pb_istream_t from buffer or callback
pb_decode(&stream, FieldList, &struct)

N5 — Tag loop with hard limits

  1. Read tag → field number / wire type.
  2. Match against the field list.
  3. Decode into static storage; if repeated count would exceed max → fail.
  4. Unknown fields: skip by wire type (and optional callbacks).
  5. Nested: decode length-delimited into nested struct (or callback).

N6 — Success means “fits the static contract”

A message that is valid Protobuf for protobuf-c can still be rejected by nanopb if it exceeds configured maxima—by design for embedded safety.

Side-by-side with protobuf-c pack/unpack

Stage protobuf-c nanopb
Prepare Fill struct (heap pointers OK) Fill static struct / set counts
Size get_packed_size unlimited logical Worst-case from maxima or sizing encode
Encode pack into caller buffer pb_encode to pb_ostream_t
Decode unpack → heap tree pb_decode into preallocated struct
Teardown free_unpacked Usually nothing (stack/static)
Hostility Need external size cap Maxima + stream limits help; still validate

When to choose which

Situation Lean
Linux/service C, variable-length documents protobuf-c
MCU, no heap or tiny heap nanopb
Same process as desktop tools needing full unknown-field round-trip Often protobuf-c
Sensor stream with fixed max samples nanopb
Team already owns protobuf-c everywhere Stay; don’t dual-stack without reason

Product/polyglot choice of “Protobuf or not” is 301; this page is which C engine.

In this suite

Entry Role
protobuf-c Classic path; see protobuf-c article
nanopb c/src/serializers/ser_nanopb.c — separate log name; version 0.4.9
Shared helpers Some C schema entries share fixture/wire helpers—read the language C Overview and serializer notes for what is timed and what fidelity means
C Results Compare within C and schema-driven family (301 using this suite)

Do not treat a faster nanopb row as “protobuf-c is wrong for servers,” or the reverse for MCUs.

Common mistakes

  • Using nanopb without setting max sizes, then “fixing” by enabling unbounded dynamic mode everywhere (loses the point).
  • Assuming nanopb decode allocates like protobuf-c.
  • Mixing generated headers from different generators in one translation unit.
  • Cross-ranking C Results against Python/Rust for engine choice (cross-language fidelity).
  • Calling a max-size reject a “wire bug” when the peer used protobuf-c with no caps.

What this article is not

  • Full nanopb options reference (see upstream nanopb docs).
  • upb-C or other C bindings.
  • Hand-rolled wire lab (lab).

Key takeaways

  • Same wire, different memory contracts.
  • protobuf-c = descriptor + heap-friendly pack/unpack.
  • nanopb = static budgets + streams; fails closed when data exceeds caps.
  • Suite: two names, one language—compare fairly, choose by deployment constraints.