Validation starts with Protobuf's generated type contracts and extends into explicit schema-level rules with protovalidate.
Generated Protobuf code enforces the schema's structural types: strings are strings, integers are numbers, repeated fields are collections, and nested messages have the expected shape.
This is useful validation, but it is intentionally limited. A schema type can tell you that a field is a string; it cannot tell you that the string is a valid email address, that an age is in range, or that two fields satisfy a business rule.
The next layer is to describe those expectations in the schema itself, using annotations that tools can read consistently across services and languages.
message SignupRequest { string email = 1; uint32 age = 2; repeated string roles = 3; }
A protovalidate rule is a field option. You write it in the brackets after a field, where any other option goes, so the rule travels with the schema it constrains: same file, same review, same generated descriptors. Every language's protovalidate library reads the rules back out of those descriptors and enforces them at runtime.
A file that uses rules imports them like any other schema, with import "buf/validate/validate.proto";. Nothing else about the field changes — drop the options and you have plain Protobuf again.
string email = 3 [(buf.validate.field).string.email = true];
- (buf.validate.field)
- The option protovalidate defines. The parentheses mark it as an extension, so this is a custom option rather than one built into Protobuf.
- .string.email
- The rule: the field's type, then a rule that type offers. A uint32 field takes numeric rules, a repeated field takes list rules.
- = true
- The value. Some rules are toggles; others take a number, a string, or a list of allowed values.
Length and format
message Account {
string id = 1 [
(buf.validate.field).string.uuid = true
];
string name = 2 [
(buf.validate.field).string.min_len = 2,
(buf.validate.field).string.max_len = 50
];
string email = 3 [
(buf.validate.field).string.email = true
];
}
Ranges
message Membership {
uint32 age = 1 [
(buf.validate.field).uint32 = {
gte: 18
lt: 120
}
];
}
Enum values
message Assignment {
Role role = 1 [
(buf.validate.field).enum = {
defined_only: true
}
];
enum Role {
ROLE_UNSPECIFIED = 0;
ROLE_USER = 1;
ROLE_ADMIN = 2;
}
}
Must be set
message LoginEvent {
string user_id = 1 [
(buf.validate.field).string.uuid = true
];
google.protobuf.Timestamp event_time = 2 [
(buf.validate.field).required = true
];
}
Lists and maps
message Team {
repeated string roles = 1 [
(buf.validate.field).repeated = {
min_items: 1
unique: true
items: {
string: {min_len: 1}
}
}
];
map<string, int32> quotas = 2 [
(buf.validate.field).map = {
max_pairs: 10
values: {
int32: {gte: 0}
}
}
];
}
Rules that span fields
message DateRange {
google.protobuf.Timestamp start = 1 [
(buf.validate.field).required = true
];
google.protobuf.Timestamp end = 2 [
(buf.validate.field).required = true
];
option (buf.validate.message).cel = {
id: "date_range.start_before_end"
message: "start must be before end"
expression: "this.start < this.end"
};
}
The lab below runs rules like these in your browser. Edit the data, or open the schema and change the rules, and the violations follow.
The lab enforces rules like those above, written in CEL and read straight out of the schema by protovalidate — no generated validation code, no rules living in a service. Edit the JSON, load an example, or edit the schema itself and watch the violations on the right keep up.
Test Data (JSON)
Rules Enforcement
Validation Strategy
By putting validation in the schema, you ensure that every part of your system enforcing the contract applies the exact same rules. This eliminates "validation drift" across your entire stack, not only between microservices. For instance, you can use the same rules to validate a form on your web frontend (using TypeScript) before the request ever hits your backend (running Go, Java, etc.).
Next
Efficiency
How much space the binary encoding saves against JSON, where those savings concentrate, and how the comparison shifts once you gzip both.