gRPC With. Net Proto Types Created: 23 Mar 2026 Updated: 23 Mar 2026

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:

// This is a .proto file
message Person {
string name = 1;
int32 age = 2;
}

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.

ValueBytes UsedExplanation
00 bytesZero is the default, so it's not even sent!
11 byteTiny value = tiny package
1271 byteStill fits in 1 byte
1282 bytesJust crossed the 1-byte limit
16,3832 bytesMaximum for 2 bytes
1,000,0003 bytesModerate value
-110 bytes!Negative numbers are a disaster with basic varint
⚠ Important gotcha: Notice that -1 uses 10 bytes with basic varint encoding! This is because computers store negative numbers using a method called "two's complement," which makes -1 look 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 ValueZigZag Transforms ToThen Varint Uses
000 bytes
-111 byte
121 byte
-231 byte
241 byte
-1001992 bytes
1002002 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.

ValueVarint (stretchy) BytesFixed32 (rigid) BytesWinner
51 byte4 bytesVarint
1,0002 bytes4 bytesVarint
1,000,0003 bytes4 bytesVarint
300,000,0005 bytes4 bytesFixed
4,000,000,0005 bytes4 bytesFixed

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 TypeC# TypeEncodingBest For
int32intVarintSmall positive integers (age, count)
int64longVarintLarge positive integers
uint32uintVarintValues that are never negative
uint64ulongVarintLarge values that are never negative
sint32intZigZag + VarintValues often negative (temperature, offset)
sint64longZigZag + VarintLarge values often negative
fixed32uintAlways 4 bytesLarge values, IDs, hashes
fixed64ulongAlways 8 bytesLarge IDs, timestamps
sfixed32intAlways 4 bytesLarge signed values
sfixed64longAlways 8 bytesLarge signed values
floatfloatAlways 4 bytesDecimal numbers (~7 digit precision)
doubledoubleAlways 8 bytesDecimal numbers (~15 digit precision)
boolboolVarint (1 byte)True / false values
stringstringLength + UTF-8 bytesText
bytesByteStringLength + raw bytesBinary 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.

message User {
int32 age = 1; // C#: int
int64 population = 2; // C#: long
}

How they work: They use varint encoding, which is great for small positive numbers but terrible for negative numbers.

ValueBytes on Wire
0Not sent (default)
1 to 1271 byte
128 to 16,3832 bytes
16,384 to 2,097,1513 bytes
-110 bytes!
-10010 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)

message Stats {
uint32 view_count = 1; // C#: uint
uint64 file_size = 2; // C#: ulong
}

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

message Weather {
sint32 temperature = 1; // C#: int
sint64 altitude_delta = 2; // C#: long
}

How they work: They use ZigZag encoding before varint, making negative numbers just as cheap as positive ones.

Here's the dramatic difference:

Valueint32 (varint only)sint32 (ZigZag + varint)
11 byte1 byte
-110 bytes1 byte
1001 byte2 bytes
-10010 bytes2 bytes
1,000,0003 bytes3 bytes
-1,000,00010 bytes3 bytes
Think of it this way: Using int32 for negative numbers is like shipping a feather in a refrigerator box. Using sint32 wraps 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

message Record {
fixed32 hash_value = 1; // C#: uint (always 4 bytes)
fixed64 large_id = 2; // C#: ulong (always 8 bytes)
}

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 Rangeuint32 (varint)fixed32Winner
0 – 1271 byte4 bytesVarint
128 – 16,3832 bytes4 bytesVarint
16,384 – 2,097,1513 bytes4 bytesVarint
2,097,152 – 268,435,4554 bytes4 bytesTie
268,435,456+5 bytes4 bytesFixed

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

message Measurement {
sfixed32 precise_offset = 1; // C#: int (always 4 bytes)
sfixed64 nanosecond_diff = 2; // C#: long (always 8 bytes)
}

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 TypeC# TypeSizePrecisionExample Use
floatfloat4 bytes (always)~7 decimal digitsTemperature: 36.6
doubledouble8 bytes (always)~15 decimal digitsGPS: 41.015137
How to choose: If you need high precision (GPS coordinates, financial calculations, scientific data), use double. For everything else where approximate values are fine (temperature, percentages, simple measurements), float saves 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

message User {
bool is_active = 1; // C#: bool
}

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

message User {
string name = 1; // C#: string
}

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

message Attachment {
bytes file_content = 1; // C#: Google.Protobuf.ByteString
}

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 of string as a letter — it must be in a readable language (UTF-8). Think of bytes as a sealed package — anything can be inside and nobody checks the contents.

In C#, ByteString is immutable (cannot be changed after creation):

// Creating a ByteString from a byte array
var data = ByteString.CopyFrom(new byte[] { 0x01, 0x02, 0x03 });

// Converting back to a byte array
byte[] bytes = data.ToByteArray();

8. How to Choose the Right Type

Follow this decision tree when choosing an integer type:

Is the value always non-negative (0 or above)?
|
+-- YES --- Are values frequently very large (above ~268 million)?
| +-- YES --> use fixed32 or fixed64
| +-- NO --> use uint32 or uint64
|
+-- NO (can be negative)
|
Are negative values common?
+-- YES --- Are values frequently very large in magnitude?
| +-- YES --> use sfixed32 or sfixed64
| +-- NO --> use sint32 or sint64
|
+-- NO (rarely negative)
--> use int32 or int64

For non-integer types:

NeedType
Decimal number with moderate precisionfloat
Decimal number with high precisiondouble
True / falsebool
Human-readable textstring
Raw binary data (images, files, etc.)bytes

9. Common Mistakes to Avoid

Mistake 1: Using int32 for temperatures

✘ BAD
// -20 degrees costs 10 bytes!
int32 temperature = 1;
✔ GOOD
// -20 degrees costs only 1 byte
sint32 temperature = 1;

Mistake 2: Using varint types for hash values

✘ BAD
// Hash values are random,
// often large = 5 bytes
uint32 hash = 1;
✔ GOOD
// Always 4 bytes,
// and faster to process
fixed32 hash = 1;

Mistake 3: Using string for binary data

✘ BAD
// Base64 adds ~33% size
string avatar_base64 = 1;
✔ GOOD
// Raw binary, no overhead
bytes avatar = 1;

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:
import "google/protobuf/wrappers.proto";

message Product {
// This can be null in C# (int?)
google.protobuf.Int32Value stock_count = 1;

// Now you can distinguish between:
// - stock_count = null --> "we don't know the stock"
// - stock_count = 0 --> "out of stock"
}

10. Real-World Examples

Example 1: User Profile Service

message UserProfile {
fixed64 user_id = 1; // Large ID --> fixed
string display_name = 2; // Text --> string
sint32 timezone_offset = 3; // UTC offset (can be negative) --> sint
uint32 login_count = 4; // Always positive, small --> uint
double latitude = 5; // GPS needs precision --> double
double longitude = 6; // GPS needs precision --> double
bytes avatar = 7; // Image data --> bytes
bool is_verified = 8; // True/false --> bool
}

Example 2: IoT Sensor Data

message SensorReading {
fixed64 device_id = 1; // Large device ID --> fixed
sint32 temperature = 2; // Can be negative --> sint
uint32 humidity_percent = 3; // 0-100, always positive --> uint
float pressure_hpa = 4; // Moderate precision --> float
fixed64 timestamp_nanos = 5; // Nanosecond timestamps are huge --> fixed
}

Example 3: Financial Transaction

message Transaction {
fixed64 transaction_id = 1; // Random large ID --> fixed
fixed64 sender_id = 2; // User ID --> fixed
fixed64 receiver_id = 3; // User ID --> fixed
sint64 amount_cents = 4; // Can be negative (refund) --> sint
string currency_code = 5; // "USD", "EUR" --> string
fixed64 timestamp = 6; // Unix timestamp --> fixed
string description = 7; // Human-readable note --> string
}

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.

ScenarioBest TypeWhy
Small positive integersint32 / uint32Varint is compact for small values
Frequently negative integerssint32 / sint64ZigZag avoids the 10-byte penalty
Large, random, or uniformly distributed valuesfixed32 / fixed64No varint overhead, faster to serialize
Decimal numbersfloat / doubleOnly two options; double for precision
Binary databytesNo UTF-8 overhead, no base64 inflation
Nullable valuesWrapper typesDistinguishes "zero" from "not set"
Share this lesson: