A message is a named group of fields, and it is where most of a schema lives. The SearchRequest here holds a string called query and two int32 values for paging. Each field gets a number, and those numbers are what actually travel in the encoded bytes.
A field can hold another message. A SearchResponse can carry a list of Result messages without you flattening anything by hand. You can also declare one message inside another when the inner type only makes sense there, which is what nesting means. Either way the declaration only describes data. There is no inheritance between messages, and no behavior attached to them in the schema.
What you get in generated code depends on the language and the generator. Go and Rust give you a struct. TypeScript gives you an object type. Java gives you a class with a builder. Most generators add accessors and serialization helpers on top, and you reach fields by name from there rather than by number.
Adding a field is the common safe change, as long as you give it a new number and leave the existing ones alone. Code compiled against the older schema skips the number it does not recognize and keeps parsing the rest, so a server can send a new field before any client knows about it. The full set of rules for what you can change is in Protobuf's guide to updating a message type.
Each set field becomes a numbered entry in the output. Try renaming a JSON key: the encoder rejects names the schema doesn't declare.
edition = "2024"; package demo.v1; message SearchRequest { string query = 1; int32 page_number = 2; int32 results_per_page = 3; }
Compiling the schema...
Next
Fields
The anatomy of a Protobuf field: types, names, and field numbers, why numbers must never change, and which numbers encode smallest on the wire.