Lesson 2 · Foundations

Protobuf: messages & fields

The contract — and why the field number is sacred.

Your win: read and write a Protobuf message, and explain why you can rename a field freely but must never reuse or renumber one — the rule buf's breaking-check exists to protect.

A message is a typed, numbered record

Protobuf messages are declared in a .proto file. Each field has a type, a name, and — crucially — a number.1

proto/spike/v1/email.proto
syntax = "proto3";
package spike.v1;
option go_package = "…/spike/v1;spb";   // → Go alias spb, in pkg/manabuf

message SendEmailPayload {
    message Content { string PlainText = 1; string HTML = 2; }  // nested
    string subject     = 1;   // scalar
    Content content    = 2;   // composite (another message)
    repeated string recipients = 3;   // repeated = a list
    EmailType type     = 7;   // enum
}
enum EmailType { EMAIL_TYPE_UNSPECIFIED = 0; TRANSACTIONAL = 1; }  // first MUST be 0

This compiles to a Go struct (in pkg/manabuf/spike/v1, alias spb) with fields Subject, Content, Recipients []string, etc.

The field number is the wire identity

The rule that matters most On the wire, Protobuf writes each field by its number, never its name. So renaming a field is safe (the number is unchanged), but reusing or renumbering a field is a breaking change — old data and other services will misread the bytes.2

Field-number facts worth knowing: they range 1–536,870,911; numbers 19,000–19,999 are reserved; and 1–15 encode in a single byte, so hot fields get low numbers. In proto3, every field is optional-ish (has a zero value) — echoing Go's zero values you learned in the Go course.

Anchor — why make lint-proto exists Our repo runs buf purely to lint protos and detect breaking changes (proto/buf.yaml, make lint-proto) — precisely to stop someone reusing a field number and corrupting the contract. (Codegen is a separate step — Lesson 3.) Enum first-value *_UNSPECIFIED = 0 is enforced too.
Common trap Deleting a field? Reserve its number (reserved 4;) so no one accidentally reuses it later. Removing a field without reserving its number is how wire-compatibility bugs sneak in.
Read this next

Protocol Buffers — Language Guide (proto3)

The definitive guide to messages, scalar types, repeated, enums, and the field-number rules. Read the "Assigning Field Numbers" section closely.

protobuf.dev/programming-guides/proto3

Check yourself (from memory)

Q1. In a proto message, what identifies a field on the wire?

The number is the wire identity; the name only exists in source/generated code.

Q2. Changing a field's name (keeping its number) is…

Wire-compatible — only the number matters on the wire. Reusing/renumbering, by contrast, corrupts data.

Q3. In proto3, an enum's first value must be…

Zero, and named *_UNSPECIFIED — it's the field's zero value when unset.
Why must you never reuse/renumber a proto field number, but renaming is fine?
recall, then click to reveal
The field number — not the name — is what's serialized on the wire. Old data and other services identify a field by its number, so reusing/renumbering silently misreads bytes (a data-corrupting breaking change). The name lives only in source/generated code, so renaming is wire-compatible. This is exactly what buf's breaking-change check (make lint-proto) guards — and why you reserved a deleted field's number.
Curious about oneof, map fields, or how proto3 handles optional/presence? Ask me.

1. Protocol Buffers — Language Guide (proto3).

2. protobuf.dev — Assigning field numbers.