Defining Services with Protobuf

A complete .proto file, protobuf field numbering, and scalar types.

A complete .proto file

Protobuf
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 .proto files' 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:

Protobuf
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 2 meaning title will be silently misread as whatever field 2 means now.
  • Renaming a field is safe — only the number matters on the wire, so title could be renamed to name without breaking compatibility, as long as the number stays 2.
  • Removing a field should mark its number reserved so it's never accidentally reused:
    Protobuf
    message 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>:

Protobuf
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

Bash
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 = N after a field as a default value (a common first guess) rather than its wire-format field number.
  • Editing generated code (the .pb.go/.cs output) by hand — always change the .proto file and regenerate; hand edits are silently overwritten on the next protoc run.

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.