Nanopb starts small, but firmware has a way of growing. A few extra fields here, a generous buffer there, and one day the linker announces that RAM or flash is gone. The good news is that nanopb gives you unusual control over its footprint, because nearly every byte it uses is a byte you declared somewhere. This guide shows you where nanopb memory actually goes, in structs, in code, and in working buffers, and walks through the practical techniques that keep a nanopb based firmware small: honest sizes in the .options file, the right field types, and build defines like PB_NO_ERRMSG that trim the runtime itself.
Where the memory goes: three buckets
Every byte nanopb costs you falls into one of three buckets, and each bucket has its own fixes. The first is struct memory: the RAM your message structs occupy, driven by your .proto design and your .options sizes. The second is code size: the flash taken by pb_encode.c, pb_decode.c, pb_common.c, and your generated .pb.c files, including their descriptor tables. The third is working memory: encode and decode buffers and any stream state, which lives on the stack or in buffers you provide. Measure all three before optimizing any of them, because the biggest bucket is not always the one people guess.
Bucket one: size your structs honestly
Static fields make struct cost perfectly transparent, and perfectly unforgiving. A label at max_size:64 costs 64 bytes in every SensorMessage, whether real labels are 6 characters or 60. A samples array at max_count:32 costs 32 entries in every message of that type, all the time. Multiply a few careless sizes across a few message types and kilobytes evaporate.
The fix is unglamorous and effective: size from the protocol’s real limits. If labels come from your own provisioning system with a 12 character cap, declare max_size:16 and enforce the cap at provisioning. If a repeated field carries at most a screenful of results, say so. Then read your generated headers, because nanopb shows you the true struct sizes there. Treat a growing .pb.h the way you treat a growing binary: ask every field to justify itself.
Choose field types with memory in mind
Field types move memory between buckets. Static fields pay full capacity in RAM around the clock. That is perfect for small, frequent messages and wasteful for a rare giant one. A configuration blob or log payload that appears once an hour should not force every message struct to carry its worst case size. Give that payload its own message, or make it a callback field so it streams through without a full buffer, or a pointer field under PB_ENABLE_MALLOC if your project accepts heap use.
Callback fields deserve special mention in a memory guide. Because nanopb calls your code to produce or consume the data, a callback field costs almost nothing in struct RAM no matter how large the logical payload grows. For firmware blobs, file transfers, and anything streamed, callbacks are the difference between a protocol that fits and one that never could.
Bucket two: trim the runtime and generated code
The nanopb runtime is small, but flash budgets on tiny parts still reward trimming. The most direct lever is PB_NO_ERRMSG. Nanopb’s streams carry human readable error strings that are gold during development and dead weight in production. Define PB_NO_ERRMSG for release builds and those strings disappear from your binary. Keep them in debug builds; the day you strip them is the day after your error handling is proven, not before.
Generated code also scales with your message design. Every field adds descriptor data and handling code. Deeply nested message trees and dozens of message types add up in .pb.c tables. This is not an argument for a starved protocol; it is an argument for deleting fields you no longer use and resisting speculative fields added just in case. The .proto file is a budget document as much as a contract.
Bucket three: buffers and stack discipline
Working memory is where runtime surprises live. An encode buffer sized for the absolute worst case message, allocated on the stack inside a deeply nested call, can blow a small stack even when the average message is tiny. Prefer a static or global scratch buffer for encoding, sized from your measured worst case, and reuse it. Remember that pb_encode() reports stream.bytes_written, so log real sizes during testing and size the production buffer from evidence.
Callback streams trim this bucket too. A stream that writes straight to a UART or radio as encoding proceeds never needs a message sized buffer at all, which is why nanopb’s stream design matters most on the smallest parts. Helpers like pb_encode_varint() let you hand frame data through the same stream when you roll your own packet formats.
Defines that change the equation
- PB_NO_ERRMSG removes error message strings to save flash in release builds.
- PB_ENABLE_MALLOC unlocks pointer fields. It can save RAM for rare large payloads and cost you determinism, so treat it as a scalpel, not a default.
- PB_FIELD_32BIT supports larger field numbers and bigger messages. It slightly increases some internal sizes, so enable it when your protocol needs it, not just in case.
Whatever you define, define it once, project wide. Nanopb headers react to these switches, and a define present in one translation unit but missing in another creates mismatched struct layouts that fail in ways nobody enjoys debugging.
Protocol design is memory design
The cheapest byte is the field that was never added. Prefer compact scalar types that match your data: a device id that fits in a uint32 should not ride in a string. Split hot and cold data into separate messages so the every second telemetry message stays tiny while the rare diagnostic dump pays its own way. Keep strings for genuinely human text and enums or integers for everything else. Review your .proto file with the same suspicion you would apply to a struct in a memory constrained driver, because with nanopb, the .proto file effectively is one.
Measure, then measure again
Firmware memory work rewards evidence over folklore. Your linker map file tells you exactly what the runtime and generated code cost in flash. The generated headers tell you what every struct costs in RAM. A few test encodes tell you your real message size distribution. Collect those three numbers before and after any optimization pass. In practice, the wins almost always arrive in the same order: tightening .options sizes, splitting or streaming one oversized field, and flipping PB_NO_ERRMSG for release. Small, boring, repeatable wins.
A realistic sizing example
Numbers make this concrete. Take the SensorMessage from the other guides: a device id, a float temperature, and a label. With the label honestly capped at max_size:16, the whole struct is roughly two dozen bytes of RAM, and the encoded message on the wire is smaller still. Now imagine the label had been declared at 128 bytes just in case. The protocol did not change, the wire format did not change, yet every SensorMessage in the system quietly costs an extra hundred plus bytes of RAM forever. Multiply that habit across a dozen message types and you have found your missing kilobyte. Memory optimization in nanopb is usually that unglamorous: find the declarations that were guesses and replace them with facts.
Conclusion
Keeping nanopb firmware small is less about tricks and more about accounting. Know your three buckets, struct RAM, code flash, and working buffers. Size static fields from real limits, push rare bulk into callbacks or separate messages, strip error strings in production, and let the linker map and generated headers audit your decisions. Do that, and nanopb stays true to its founding promise: a protobuf implementation your smallest chip can afford.
Read next:


