Skip to content

Generated code already checks your types. A string field always holds a string, and an int32 always holds a number. With protovalidate you can also write range and format rules straight into the schema, next to the fields they constrain.

A schema type can tell you a field is a string, but it cannot tell you the string is a valid email address. It cannot tell you an age is in range, or that two fields satisfy some business rule between them.

Validation rules describe those expectations in the schema itself. You add annotations to the fields, and your services run a validator that checks each message against them. Services in different languages read the same rules from the same schema.

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 the field, right where any other option goes. That means the rule lives in the same file as the field it constrains, so it goes through review with the field and ends up in the same generated descriptors. The protovalidate library for each language reads the rules from 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, and removing the options leaves a plain Protobuf schema.

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 for that type. 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

ACCOUNT.PROTO
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

MEMBERSHIP.PROTO
message Membership {
  uint32 age = 1 [
    (buf.validate.field).uint32 = {
      gte: 18
      lt: 120
    }
  ];
}

Enum values

ASSIGNMENT.PROTO
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

LOGIN_EVENT.PROTO
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

TEAM.PROTO
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

DATE_RANGE.PROTO
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"
  };
}

By default, validation skips unset fields that track presence. In edition 2024 every singular field does, so a message that leaves email out passes string.email without complaint. Fields without presence, including repeated fields and maps, are still checked: an empty list fails repeated.min_items.

  • {}Passes. The field is unset, so its rules are skipped.
  • { "email": "not-an-email" }Fails. The field is set, so the email rule runs.
  • { "email": "hiro@metaverse.com" }Passes. The field is set and the value is valid.

When the field has to be there, add (buf.validate.field).required = true next to the other rules. Then the empty message fails too.

In TypeScript, running the rules takes a createValidator() call and one validate() per message. Every protovalidate library has the same shape, and Go servers built on ConnectRPC can skip the manual call entirely: the validate interceptor checks every request and rejects invalid ones before your handler runs.

validate.ts
import { create } from "@bufbuild/protobuf";
import { createValidator } from "@bufbuild/protovalidate";
import { UserSchema } from "./gen/demo/v1/user_pb";

const validator = createValidator();
const user = create(UserSchema, { email: "not-an-email" });

const result = validator.validate(UserSchema, user);
if (result.kind === "invalid") {
  for (const violation of result.violations) {
    console.log(violation.toString());
  }
}

Rules like the ones above are written in CEL and read straight out of the schema by protovalidate. There is no generated validation code, and the rules aren't kept separately in each service.

The same rules run here, in your browser. Edit the JSON, load an example, or open the schema and change the rules, and the violations on the right keep up.

Test Data (JSON)

JSON input
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "Hiro Protagonist",
  "email": "hiro@metaverse.com",
  "age": 30,
  "role": 2,
  "birthDate": {
    "year": 1996,
    "month": 1,
    "day": 1
  }
}

Rules Enforcement

Validation status

Waiting for
valid input

Validation Strategy

Putting validation in the schema means every service enforces the exact same rules, in every language. Your TypeScript frontend can check a form against the same rules your Go or Java backend runs when the request arrives.

Next

Efficiency

How much space the binary encoding actually saves against JSON, and what happens to that gap once you gzip both.