Everything you do with nanopb eventually funnels into two function calls. pb_encode() turns a filled C struct into standard protobuf bytes, and pb_decode() turns bytes back into a struct. Two calls sounds simple, and it genuinely is, once you understand the stream objects they work through and the handful of habits that keep them from failing. This guide walks through encoding and decoding step by step with a running SensorMessage example, explains how nanopb streams really work, and shows you how to check return values and sizes so a failed encode never turns into a silent mystery in the field.
The cast of characters
Three things appear in almost every nanopb encode or decode you will ever write.
- Your message struct, generated from your .proto file, for example a SensorMessage with a device id, a temperature, and a label.
- The field descriptor for that message, a generated constant named like
SensorMessage_fields, which tells the runtime how the struct maps to wire fields. - A stream. Encoding uses an output stream,
pb_ostream_t, and decoding uses an input stream,pb_istream_t. Streams are nanopb’s way of saying: bytes go out through this, or come in through that.
For the common case of a memory buffer, nanopb hands you ready made constructors: pb_ostream_from_buffer() for writing and pb_istream_from_buffer() for reading. Streams can also be backed by your own callbacks, which is how people send encoded data straight to a UART or socket, but buffers are the right place to learn.
Encoding a message, line by line
Here is a complete, honest encode of a small telemetry message:
SensorMessage msg = SensorMessage_init_zero; msg.device_id = 7; msg.temperature = 21.5f; strcpy(msg.label, "roof"); uint8_t buffer[64]; pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer)); bool ok = pb_encode(&stream, SensorMessage_fields, &msg); size_t length = stream.bytes_written;
Walk through it slowly. The struct starts zero initialized, which clears counts and presence flags so nothing stale leaks into the output. You fill the fields with plain C assignments. The output stream wraps your 64 byte buffer. pb_encode() walks the descriptor, reads each field from the struct, and writes protobuf bytes into the buffer through the stream. When it returns true, stream.bytes_written tells you exactly how many bytes are real data. Those bytes, and only those bytes, are your message.
Decoding a message, line by line
Decoding mirrors encoding almost perfectly:
SensorMessage msg = SensorMessage_init_zero; pb_istream_t stream = pb_istream_from_buffer(buffer, length); bool ok = pb_decode(&stream, SensorMessage_fields, &msg);
The input stream wraps the received bytes and their true length, which you get from your transport, a UART read count, a BLE payload length, a file size. pb_decode() reads fields off the wire and drops them into the struct wherever the field numbers match. Start from a zeroed struct again, because fields absent on the wire keep whatever values the struct already holds, and you want those to be predictable zeros rather than leftovers from a previous message.
Check the return value, every single time
pb_encode() and pb_decode() both return a bool, and ignoring it is the most expensive shortcut in nanopb code. A false from encode usually means your output buffer was smaller than the message needed; the stream ran out of room mid message. A false from decode usually means the bytes were not the message you passed a descriptor for, the data was truncated, or a field violated the sizes in your .options file.
When a call fails, the stream carries an error message string you can log during development. Get into the habit of logging failures with the message type and the stream state while you build. In production firmware you can compile those strings out with PB_NO_ERRMSG to save flash, but during development they are the difference between a two minute fix and an afternoon of guessing.
Sizing your buffers with confidence
Beginners often ask how big the encode buffer should be. The honest answer is that a protobuf message is compact, and you can bound it. Start by thinking about worst case content: your longest label at its max_size, your repeated fields at their max_count, plus a few bytes of field tags and length prefixes per field. For a small message like SensorMessage, 64 bytes is generous. After encoding, stream.bytes_written is ground truth; log it during testing and you will quickly learn your message’s real size range.
If encode returns false with a full looking message, do not just double the buffer and move on without understanding. A wildly oversized string copied into a fixed array, or a count field set higher than the array holds, can also break encoding. The buffer size is often the messenger, not the culprit.
Streams beyond buffers
Buffers are simple, but the stream design earns its keep when data should not sit in memory at all. An output stream can be backed by a callback that writes each chunk directly to a UART, a radio, or a file as pb_encode() produces it, so even a large message never needs a full sized buffer in RAM. Input streams work the same way in reverse. Helpers like pb_encode_varint() let you write low level pieces through a stream when you hand craft framing around your messages. Learn buffers first, then reach for callback streams when a real constraint, usually RAM, asks for them.
Presence, optional fields, and zeroes
One decoding subtlety deserves attention. In proto2 style definitions, optional fields generate has_ flags in the struct, and after decode those flags tell you if the sender actually included the field. In proto3 style, plain scalar fields have no presence flag and simply arrive as zero when absent. Either way, the zero initialized struct pattern protects you: absent data reads as zero, never as garbage. Check has_ flags where they exist before trusting a value that drives a decision.
Test the round trip on your PC first
Before any of this runs on a microcontroller, prove it on your development machine. The nanopb runtime is portable C, so a tiny desktop test program can include your generated files, encode a message, print the bytes, decode them back, and print the struct. That five minute exercise validates your whole chain: the .proto file, the options sizes, the generator, and your understanding of the calls. Teams that keep this host side test around, and feed the encoded bytes into a Python or Go protobuf decoder as a second check, catch contract mistakes at the cheapest possible moment. Embedded debugging time is precious. Spend as little of it as possible on problems a PC could have found.
You can also use the PC test to explore edge cases calmly: the empty message, the longest allowed label, the repeated field at exactly max_count and one past it. Watching encode and decode behave at the boundaries teaches you more about your protocol than a week of normal traffic.
Conclusion
Encoding and decoding with nanopb is a tight loop: zero the struct, fill or prepare it, wrap a buffer in the matching stream, call pb_encode() or pb_decode() with the message descriptor, and honor the return value. Respect buffer sizes, log failures while you develop, and your messages will round trip cleanly between a microcontroller and anything else that speaks protobuf. When a call does fail, the troubleshooting guide below maps each symptom to its likely cause.
Read next:



Pingback: Nanopb Field Types: Static, Pointer, Callback
Pingback: How to Use Nanopb with Arduino and ESP32