Protocol Buffers Scalar Types
What Is Protocol Buffers?
When two programs need to talk to each other over a network (for example, a mobile app talking to a server), they need to agree on how to package the data. Just like you need to put your belongings in a box before shipping them, programs need to put data into a format that can travel over the wire.
Protocol Buffers (often called "protobuf") is Google's system for packaging data. It's the format used by gRPC to send messages between a client and a server.
Real-world analogy: Think of protobuf as a very efficient packing system. Instead of writing a letter in plain English (like JSON does), protobuf converts your data into a super compact binary format — like using vacuum-sealed bags instead of regular boxes. The result is smaller, faster to pack, and faster to unpack.
You define your data structure in a .proto file, and protobuf generates C# classes for you automatically:
Protobuf turns this into a C# class with a string Name property and an int Age property. You never write the serialization code yourself.
2. Why Are There So Many Types?
If you look at the protobuf type list, you'll notice something strange: there are 10 different integer types that all become just int, long, uint, or ulong in C#. Why?
The answer is: they differ in how they pack the data for transport.
Real-world analogy: Imagine you're shipping packages. You have three options:
Option A (Varint): Use a bag that stretches to fit the item. A marble gets a tiny bag; a basketball gets a big bag. Great if most items are small, but awkward for consistently large items.
Option B (ZigZag): Same stretchy bag, but with a clever trick: you fold the item first so that both small positive AND small negative items fit in tiny bags.
Option C (Fixed): Use a rigid box that's always the same size, no matter what's inside. A marble wastes space, but there's no time spent measuring and stretching. Perfect for large items.
Even though the item inside (your integer) is the same, the packaging method changes how much space it takes on the wire and how fast it is to pack/unpack.
3. How Data Gets Sent Over the Wire
Before we look at each type, let's understand the three packaging methods that protobuf uses. This is the key to understanding why different types exist.
3.1 Varint — The Stretchy Bag
Varint stands for "variable-length integer." It uses fewer bytes for smaller numbers and more bytes for larger numbers. Think of it as a rubber band that wraps tightly around small objects but stretches for bigger ones.
| Value | Bytes Used | Explanation |
|---|---|---|
0 | 0 bytes | Zero is the default, so it's not even sent! |
1 | 1 byte | Tiny value = tiny package |
127 | 1 byte | Still fits in 1 byte |
128 | 2 bytes | Just crossed the 1-byte limit |
16,383 | 2 bytes | Maximum for 2 bytes |
1,000,000 | 3 bytes | Moderate value |
-1 | 10 bytes! | Negative numbers are a disaster with basic varint |
⚠ Important gotcha: Notice that-1uses 10 bytes with basic varint encoding! This is because computers store negative numbers using a method called "two's complement," which makes-1look like a gigantic positive number internally. Varint faithfully encodes that giant number, resulting in maximum byte usage.
3.2 ZigZag — The Clever Folder
ZigZag encoding solves the negative number problem. Before applying varint, it rearranges numbers so that values close to zero (whether positive OR negative) get small encodings:
| Original Value | ZigZag Transforms To | Then Varint Uses |
|---|---|---|
0 | 0 | 0 bytes |
-1 | 1 | 1 byte |
1 | 2 | 1 byte |
-2 | 3 | 1 byte |
2 | 4 | 1 byte |
-100 | 199 | 2 bytes |
100 | 200 | 2 bytes |
How it works in plain English: ZigZag takes the number line and "folds" it like an accordion. Instead of going 0, 1, 2, 3... it goes 0, -1, 1, -2, 2, -3, 3... This way, numbers near zero always get small labels, regardless of their sign.
3.3 Fixed — The Rigid Box
Fixed encoding always uses the exact same number of bytes, no matter how big or small the value is. For 32-bit types, it's always 4 bytes. For 64-bit types, it's always 8 bytes.
| Value | Varint (stretchy) Bytes | Fixed32 (rigid) Bytes | Winner |
|---|---|---|---|
5 | 1 byte | 4 bytes | Varint |
1,000 | 2 bytes | 4 bytes | Varint |
1,000,000 | 3 bytes | 4 bytes | Varint |
300,000,000 | 5 bytes | 4 bytes | Fixed |
4,000,000,000 | 5 bytes | 4 bytes | Fixed |
Bonus advantage: Fixed encoding is also faster to encode and decode because the computer can just copy the bytes directly from memory — no math needed.
4. Quick Reference Table
| Protobuf Type | C# Type | Encoding | Best For |
|---|---|---|---|
int32 | int | Varint | Small positive integers (age, count) |
int64 | long | Varint | Large positive integers |
uint32 | uint | Varint | Values that are never negative |
uint64 | ulong | Varint | Large values that are never negative |
sint32 | int | ZigZag + Varint | Values often negative (temperature, offset) |
sint64 | long | ZigZag + Varint | Large values often negative |
fixed32 | uint | Always 4 bytes | Large values, IDs, hashes |
fixed64 | ulong | Always 8 bytes | Large IDs, timestamps |
sfixed32 | int | Always 4 bytes | Large signed values |
sfixed64 | long | Always 8 bytes | Large signed values |
float | float | Always 4 bytes | Decimal numbers (~7 digit precision) |
double | double | Always 8 bytes | Decimal numbers (~15 digit precision) |
bool | bool | Varint (1 byte) | True / false values |
string | string | Length + UTF-8 bytes | Text |
bytes | ByteString | Length + raw bytes | Binary data (images, files) |
5. Integer Types Explained
5.1 int32 / int64 — The Default Choice
These are the "standard" integer types. If you don't know which to pick, start here.
How they work: They use varint encoding, which is great for small positive numbers but terrible for negative numbers.
| Value | Bytes on Wire |
|---|---|
0 | Not sent (default) |
1 to 127 | 1 byte |
128 to 16,383 | 2 bytes |
16,384 to 2,097,151 | 3 bytes |
-1 | 10 bytes! |
-100 | 10 bytes! |
Use when: Your values are mostly positive and relatively small (ages, counts, quantities, IDs under a few million).
5.2 uint32 / uint64 — Unsigned (Never Negative)
How they work: Same as int32/int64 but they only allow positive numbers. Since there's no sign to worry about, there's no negative-number trap.
Use when: The value can never be negative — counts, sizes, quantities.
5.3 sint32 / sint64 — The Negative Number Heroes
How they work: They use ZigZag encoding before varint, making negative numbers just as cheap as positive ones.
Here's the dramatic difference:
| Value | int32 (varint only) | sint32 (ZigZag + varint) |
1 | 1 byte | 1 byte |
-1 | 10 bytes | 1 byte |
100 | 1 byte | 2 bytes |
-100 | 10 bytes | 2 bytes |
1,000,000 | 3 bytes | 3 bytes |
-1,000,000 | 10 bytes | 3 bytes |
Think of it this way: Usingint32for negative numbers is like shipping a feather in a refrigerator box. Usingsint32wraps it in a properly-sized envelope.
Use when: Your values are frequently negative — temperatures, deltas, offsets, coordinates relative to a center point.
5.4 fixed32 / fixed64 — The Consistent Boxes
How they work: Always use exactly 4 bytes (for 32-bit) or 8 bytes (for 64-bit), no matter what the value is.
When fixed beats varint for 32-bit values:
| Value Range | uint32 (varint) | fixed32 | Winner |
| 0 – 127 | 1 byte | 4 bytes | Varint |
| 128 – 16,383 | 2 bytes | 4 bytes | Varint |
| 16,384 – 2,097,151 | 3 bytes | 4 bytes | Varint |
| 2,097,152 – 268,435,455 | 4 bytes | 4 bytes | Tie |
| 268,435,456+ | 5 bytes | 4 bytes | Fixed |
Use when: Values are consistently large (above ~268 million for 32-bit), or when the values are uniformly distributed across the full range (like hash values, UUIDs, or random IDs). Also preferred when you want the fastest possible serialization speed.
5.5 sfixed32 / sfixed64 — Fixed + Signed
Same as fixed32/fixed64, but for signed values. Use when you have large signed numbers that span a wide range.
6. Floating Point Types
These are straightforward — there are only two options:
| Protobuf Type | C# Type | Size | Precision | Example Use |
|---|---|---|---|---|
float | float | 4 bytes (always) | ~7 decimal digits | Temperature: 36.6 |
double | double | 8 bytes (always) | ~15 decimal digits | GPS: 41.015137 |
How to choose: If you need high precision (GPS coordinates, financial calculations, scientific data), usedouble. For everything else where approximate values are fine (temperature, percentages, simple measurements),floatsaves half the space.
Both types always use fixed-width encoding. There is no varint option for decimals.
7. Bool, String, and Bytes
7.1 bool — True or False
Uses 1 byte for true. The value false is the default, so it uses 0 bytes — it's simply not sent.
7.2 string — Text
Stores UTF-8 encoded text. The wire format is: a length prefix (varint) followed by the UTF-8 bytes. An empty string "" is the default and uses 0 bytes.
7.3 bytes — Raw Binary Data
Stores raw binary data — images, files, encrypted content, or any sequence of bytes. Same wire format as string but without UTF-8 validation.
string vs bytes: Think ofstringas a letter — it must be in a readable language (UTF-8). Think ofbytesas a sealed package — anything can be inside and nobody checks the contents.
In C#, ByteString is immutable (cannot be changed after creation):
8. How to Choose the Right Type
Follow this decision tree when choosing an integer type:
For non-integer types:
| Need | Type |
|---|---|
| Decimal number with moderate precision | float |
| Decimal number with high precision | double |
| True / false | bool |
| Human-readable text | string |
| Raw binary data (images, files, etc.) | bytes |
9. Common Mistakes to Avoid
Mistake 1: Using int32 for temperatures
| ✘ BAD |
|---|
| ✔ GOOD |
|---|
Mistake 2: Using varint types for hash values
| ✘ BAD |
|---|
| ✔ GOOD |
|---|
Mistake 3: Using string for binary data
| ✘ BAD |
|---|
| ✔ GOOD |
|---|
Mistake 4: Forgetting default value behavior
⚠ Warning: In proto3, default values (0,"",false) are not sent on the wire. This means you cannot tell the difference between "the field was set to 0" and "the field was never set."
If you need to distinguish these cases, use wrapper types:
10. Real-World Examples
Example 1: User Profile Service
Example 2: IoT Sensor Data
Example 3: Financial Transaction
11. Summary
Protobuf has many types that map to the same C# type because they differ in how they encode data on the wire. Choosing the right type can make your messages 2x to 10x smaller for integer-heavy data.
| Scenario | Best Type | Why |
|---|---|---|
| Small positive integers | int32 / uint32 | Varint is compact for small values |
| Frequently negative integers | sint32 / sint64 | ZigZag avoids the 10-byte penalty |
| Large, random, or uniformly distributed values | fixed32 / fixed64 | No varint overhead, faster to serialize |
| Decimal numbers | float / double | Only two options; double for precision |
| Binary data | bytes | No UTF-8 overhead, no base64 inflation |
| Nullable values | Wrapper types | Distinguishes "zero" from "not set" |