Ask a room of nanopb users what confused them most in their first week and most will give the same answer: the fields. A string they expected to find as a neat array inside the struct turned out to be a function pointer, or a list behaved like nothing they had seen in C. All of that traces back to one design decision. Nanopb gives every variable length field an allocation type, and there are exactly three: static, pointer, and callback. Understand those three and nanopb’s generated code stops being surprising. This guide explains each field type in depth, shows where each one shines, and helps you choose deliberately instead of discovering your field types by accident.
Why allocation types exist at all
A C struct needs a known size. Fixed width numbers are easy: a uint32 is four bytes, forever. Strings, byte arrays, and repeated fields are the problem children, because their length depends on data that only exists at runtime. Nanopb refuses to hide that problem behind silent heap allocation, the way desktop protobuf libraries do. Instead it asks you, per field, how storage should work, and it gives you three honest answers to choose from. Your choices, declared through the .options file and related settings, decide the shape of every struct the generator produces.
FT_STATIC: the embedded default
Static fields, FT_STATIC in nanopb terms, store their data right inside the struct at a fixed capacity you declare. A label with max_size:16 becomes char label[16]. A repeated samples field with max_count:8 becomes a fixed array of eight entries plus a count field. This is the default because it is what embedded systems want: no allocation, no fragmentation, and a memory cost you can compute from the header file alone.
Static fields fail in predictable, debuggable ways. Decode a string longer than max_size and pb_decode() returns false instead of overflowing your buffer, which is exactly the behavior you want protecting a device you cannot physically reach. The cost is rigidity. Every SensorMessage carries room for the longest label and the fullest sample list, even when most messages are short. For small messages on typical microcontrollers, that trade is almost always worth it, and static fields should be your starting point for everything.
FT_POINTER: dynamic fields with malloc
Pointer fields, FT_POINTER, store a pointer in the struct and allocate the actual data on the heap as needed. A string becomes a char* that nanopb points at freshly allocated memory during decode, sized to whatever actually arrived. Repeated fields become pointers to arrays allocated to the exact count. To use pointer fields you must enable malloc support by defining PB_ENABLE_MALLOC, because nanopb will not touch the heap until you explicitly allow it.
Pointer fields earn their place when sizes genuinely vary by orders of magnitude: a firmware image chunk here, a one word status string there, in the same message type. They also help when prototyping, before real size limits are known. The costs are the classic heap costs. Allocation can fail, memory can fragment over weeks of uptime, and somebody has to free decoded data with the generated release helpers when the message is done. On a short lived or carefully managed device that is fine. On a sensor node expected to run for five years untouched, think hard before reaching for the heap.
FT_CALLBACK: your code is the storage
Callback fields, FT_CALLBACK, are the most unusual of the three and the most powerful. The struct holds no data and no pointer to data. Instead it holds function pointers, and during encoding nanopb calls your function to supply the field’s contents piece by piece, while during decoding it calls your function to hand you each piece as it arrives.
This shines in two situations. The first is streaming: data that is generated or consumed on the fly, like readings pulled from a sensor ring buffer, never needs to exist as a complete copy in RAM. The second is data too big to buffer, such as a log file or a firmware chunk passing through the device. Your callback becomes the field, reading from flash or writing to a radio as the encode or decode proceeds.
Callback fields also have a sharp edge worth respecting. If your callback returns false in the middle of an operation, the whole encode or decode aborts and returns false. That is by design, since a failed source or sink means the message cannot be completed honestly, but it means callback code needs the same care as any other error path in your firmware.
The field type you get by accident
Here is the interaction that bites beginners. If a string, bytes, or repeated field has no size in the .options file, and malloc is not enabled, the generator quietly makes it a callback field. Nothing is broken; nanopb simply had no other way to give that field storage. But your struct now contains function pointers where you expected arrays, and example code from the internet stops making sense. When generated code surprises you, check field types first. Nine times out of ten, a missing max_size or max_count in .options is the entire story.
Choosing a type per field, not per project
Mature nanopb projects rarely pick one type globally. They mix. Telemetry values and short labels sit in static fields, sized tightly from the protocol’s real limits. A rare, bulky payload, like a configuration document, rides as a pointer field or a callback field so its worst case size does not tax every message. Submessages follow the same logic: their inner fields each get their own treatment.
A practical decision order helps. Start with static for every variable length field and write down real sizes. Move a field to pointer only when its size range is huge and allocation risk is acceptable. Move a field to callback when the data naturally streams or is too large to hold. That order keeps your default posture boring and predictable, which is a compliment in firmware.
What repeated fields and submessages look like
Repeated fields follow the same three way split. Static gives you an array and a count, pointer gives you an allocated array and a count, callback hands each entry to your function one at a time. Submessages are simply structs inside structs for static allocation, which keeps the whole message one self contained block of memory you can zero with the generated _init_zero helper. That single block property is another quiet reason static fields dominate embedded nanopb code: one memset prepares the entire message, however deeply it nests.
Conclusion
Nanopb’s three field types are three answers to one question: where does variable length data live? Static fields keep it inside the struct at a size you declare, and they are the right default for embedded work. Pointer fields put it on the heap under PB_ENABLE_MALLOC, trading determinism for flexibility. Callback fields keep it in your code, streamed on demand, for the biggest and most dynamic payloads. Choose per field, declare your sizes in .options, and the generated code will always look like something you designed on purpose.
Read next:



Pingback: Nanopb vs Protobuf: Which One Should You Use?
Pingback: Nanopb Memory Usage: Keep Your Firmware Small