Defining Services with Protobuf
A complete .proto file, protobuf field numbering, and scalar types.
A complete .proto file
syntax = "proto3";
package tasks.v1;
option go_package = "example.com/tasks/pb";
service TaskService {
rpc GetTask (GetTaskRequest) returns (Task);
rpc CreateTask (CreateTaskRequest) returns (Task);
rpc ListTasks (ListTasksRequest) returns (ListTasksResponse);
}
message Task {
int32 id = 1;
string title = 2;
bool done = 3;
}
message GetTaskRequest {
int32 id = 1;
}
message CreateTaskRequest {
string title = 1;
}
message ListTasksRequest {
bool only_done = 1;
}
message ListTasksResponse {
repeated Task tasks = 1;
}
This single file is the entire contract: every language's generated client and server stubs come from running the protoc compiler (plus a language-specific plugin) against it.
Breaking it down
syntax = "proto3";— proto3 is the current, modern protobuf syntax (proto2 still exists in older codebases but shouldn't be used for new work).package tasks.v1;— a namespace for the generated code, avoiding collisions with other.protofiles' types. Including a version (v1) in the package name is a common convention for making a future breaking change explicit (tasks.v2) rather than mutating the same namespace.service TaskService { ... }— declares the RPC methods this service exposes, each with exactly one request message type and one response message type.message ... { ... }— declares a data structure, analogous to a class/struct — each field has a type, a name, and a field number.
Field numbers: the part protobuf newcomers get wrong
Every field is followed by = N — its field number, not its default value:
message Task {
int32 id = 1;
string title = 2;
bool done = 3;
}
Field numbers, not field names, are what actually get written to the binary wire format — this is the whole reason protobuf messages are so compact and so fast to parse. It also means:
- Numbers must be unique within a message and, once shipped, should never be reused or reassigned to a different field — old binary data (or an older client) encoded with number
2meaningtitlewill be silently misread as whatever field2means now. - Renaming a field is safe — only the number matters on the wire, so
titlecould be renamed tonamewithout breaking compatibility, as long as the number stays2. - Removing a field should mark its number
reservedso it's never accidentally reused:Protobufmessage Task { reserved 2; reserved "title"; int32 id = 1; bool done = 3; } - Numbers 1–15 use one less byte on the wire than 16+, so conventionally reserve the low numbers for a message's most frequently-set fields.
Scalar types
| Protobuf type | Go | C# | Typical use |
|---|---|---|---|
int32 / int64 |
int32 / int64 |
int / long |
Signed integers |
uint32 / uint64 |
uint32 / uint64 |
uint / ulong |
Non-negative integers |
bool |
bool |
bool |
Flags |
string |
string |
string |
UTF-8 text |
bytes |
[]byte |
ByteString |
Raw binary data |
double / float |
float64 / float32 |
double / float |
Decimal numbers |
Repeated fields and nested messages
repeated marks a field as a list (zero or more values) rather than a single one — protobuf's equivalent of an array/slice/List<T>:
message ListTasksResponse {
repeated Task tasks = 1;
}
Messages can nest other messages directly as field types (as Task is used inside ListTasksResponse above), and can also be declared nested lexically inside another message when they're only ever meaningful in that context.
Generating code
protoc --go_out=. --go-grpc_out=. tasks.proto
This produces generated Go types (Task, GetTaskRequest, ...) with all the marshaling logic already written, plus client and server interface stubs (TaskServiceClient, TaskServiceServer) — you implement the server interface with your actual business logic, and call the client interface as if it were a local method.
Common mistakes
- Reusing a field number after removing that field, instead of marking it
reserved— a subtle wire-compatibility bug that can silently corrupt data for clients still running an older generated version. - Treating the
= Nafter a field as a default value (a common first guess) rather than its wire-format field number. - Editing generated code (the
.pb.go/.csoutput) by hand — always change the.protofile and regenerate; hand edits are silently overwritten on the nextprotocrun.
Interview questions
Q: What does the number after a protobuf field actually mean? It's the field's number in the binary wire format — not a default value. Protobuf encodes fields by number rather than by name, which is what makes the binary encoding so compact; it also means field numbers must stay stable and unique for backward/forward compatibility once a message has shipped anywhere.
Q: How do you safely remove a field from a proto message?
Mark its number (and, optionally, its name) as reserved rather than deleting the line outright and leaving that number free to be reused. This guarantees the compiler rejects any future field accidentally reusing that number, which would otherwise be silently misinterpreted by any old client or stored data still using the original meaning.
Q: Can you rename a field in a .proto message without breaking compatibility?
Yes — only the field number is significant on the wire, not its name. Renaming title to name while keeping = 2 is fully backward-compatible; every existing client and stored payload is unaffected.